/ src / views / elements / MailElement.php
<?php
/**
 * 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
 */
namespace seekquarry\yioop\views\elements;

use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\library\HtmlSanitizer;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\models\MailAccountModel;

/**
 * Element used to draw the Mail activity. Mirrors the two-pane
 * shape of the Messages activity: a user-accounts side column on
 * the left lists registered IMAP accounts (each with its folders),
 * and an account-messages content pane on the right shows the
 * inbox listing, the body of the currently selected message, or
 * an account add / edit form. The two panes are separated by a
 * split-adjuster, matching the usermessages page convention.
 *
 * On mobile the side column and content pane are rendered
 * conditionally (one or the other, never both) so the narrow
 * viewport always shows the relevant content full-width.
 *
 * Which content the right pane shows is selected by
 * $data['CONTENT_PANE']:
 *   - selectAccount    empty pane with a "pick an account" prompt
 *   - addAccount       new-account form
 *   - editAccount      edit existing account form
 *   - inbox            envelope listing for one account
 *   - message          single-message body view
 *
 * @author Chris Pollett
 */
class MailElement extends Element
{
    /**
     * Top-level dispatcher. Renders the mail-container shell with
     * the user-accounts side column and the account-messages
     * content pane, picking which content to render in the pane
     * based on $data['CONTENT_PANE']. On mobile, hides the side
     * column once an account is selected and hides the content
     * pane until one is.
     *
     * @param array $data fields prepared by SocialComponent::userMail
     *      (and its helpers); see userMailBaseData for the scaffold
     */
    public function render($data)
    {
        $is_mobile = !empty($data['MOBILE']);
        $pane = $data['CONTENT_PANE'] ?? 'selectAccount';
        $has_selection = ($pane !== 'selectAccount');
        /* Add Account, Edit Account, and the clone sub-views
           (form and status banner) are full-width edit screens:
           they replace the entire activity container just like
           every other edit screen elsewhere in Yioop. Skipping
           the mail-container / user-accounts shell here means
           the form picks up the standard .current-activity
           typography and width instead of competing for space
           with the side-list. */
        $is_full_width_edit = in_array($pane,
            ['addAccount', 'editAccount', 'editMailsite'], true);
        ?>
        <script>
            window.MAIL_TRIANGLE_EXPANDED = <?=
                json_encode(tl(
                'mail_element_folder_triangle_expanded')) ?>;
            window.MAIL_TRIANGLE_COLLAPSED = <?=
                json_encode(tl(
                'mail_element_folder_triangle_collapsed')) ?>;
            window.MAIL_LOADING = <?=
                json_encode(tl('mail_element_loading')) ?>;
        </script>
        <?php
        if ($is_full_width_edit) {
            ?>
            <div class="current-activity mail-current-activity">
            <?php $this->renderAccountMessages($data); ?>
            </div>
            <?php
            return;
        }
        ?>
        <div class="mail-container">
        <?php
        $deselect_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&deselect=1";
        if (!$is_mobile || !$has_selection) {
            ?><div class="user-accounts"
                data-deselect-url="<?=
                $deselect_url ?>">
            <?php $this->renderUserAccounts($data); ?>
            </div><?php
        }
        if (!$is_mobile) {
            ?><div class="split-adjuster" id="split-adjuster"></div><?php
        }
        if (!$is_mobile || $has_selection) {
            ?><div class="account-messages">
            <?php $this->renderAccountMessages($data); ?>
            </div><?php
        }
        if (!$is_mobile) {
            ?><div class="mail-container-h-resizer"
                id="mail-container-h-resizer"></div><?php
        }
        ?>
        </div>
        <?php
        if (!$is_mobile) {
            ?><div class="mail-container-resizer"
                id="mail-container-resizer"></div><?php
        }
    }
    /**
     * Builds a URL for the Mail activity with optional extra query
     * parameters. Uses the /user_mail/ route prefix so requests
     * land directly on GroupController without bouncing through
     * AdminController's redirect machinery.
     *
     * @param array $data $data with CSRF token set
     * @param array $extra extra key => value pairs to append
     * @return string the assembled URL (already entity-encoded)
     */
    protected static function mailUrl($data, $extra = [])
    {
        $token = C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')];
        $base = htmlentities(B\controllerUrl("user_mail", true)) . $token;
        foreach ($extra as $key => $value) {
            $base .= "&" . urlencode($key) . "=" .
                urlencode((string) $value);
        }
        return $base;
    }
    /**
     * Renders the user-accounts side column: Add Account button at
     * the top (when external accounts are enabled) plus one
     * labelled group per registered account. Each group shows the
     * account's default folder as a clickable link with inline
     * edit / delete controls. The currently-active account gets
     * the user-accounts-active modifier class.
     *
     * @param array $data fields including ACCOUNTS, EXTERNAL_ENABLED,
     *      ACCOUNT_ID (when an account is selected)
     */
    protected function renderUserAccounts($data)
    {
        ?>
        <span id="mail-folder-ops-strings" class="none"
            data-mail-element-save="<?= tl(
                'mail_element_save') ?>"
            data-mail-element-edit-link="<?= tl(
                'mail_element_edit_link') ?>"
            data-mail-element-delete-link="<?= tl(
                'mail_element_delete_link') ?>"
            data-mail-element-folder-new="<?= tl(
                'mail_element_folder_new') ?>"
            data-mail-element-folder-new-child="<?= tl(
                'mail_element_folder_new_child') ?>"
            data-mail-element-folder-new-placeholder="<?=
                tl(
                'mail_element_folder_new_placeholder') ?>"
            data-mail-element-folder-rename="<?= tl(
                'mail_element_folder_rename') ?>"
            data-mail-element-folder-delete="<?= tl(
                'mail_element_folder_delete') ?>"
            data-mail-element-folder-delete-confirm="<?=
                tl(
                'mail_element_folder_delete_confirm', '%s') ?>"
            data-mail-element-account-rename-label="<?=
                tl(
                'mail_element_account_rename_label') ?>"
            ></span>
        <div class="socialcontrols-wrap">
        <?php $this->view->element("socialcontrols")->render($data); ?>
        </div>
        <?php
        $active_account = (int) ($data['ACCOUNT_ID'] ?? 0);
        $accounts = $data['ACCOUNTS'] ?? [];
        if (empty($accounts)) {
            return;
        }
        $toggle_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=toggleAccount";
        $folders_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=listFolders";
        $folder_expand_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=toggleFolderExpanded";
        $reorder_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=reorderAccounts";
        ?>
        <div class="user-accounts-list"
            data-reorder-url="<?= $reorder_url ?>"
            data-reorder-label="<?= tl(
                'mail_element_account_drag_label') ?>">
        <?php
        foreach ($accounts as $account) {
            $aid = (int) $account['ID'];
            $is_active = ($aid === $active_account);
            $is_mailsite = !empty($account['IS_MAILSITE']);
            $edit_url = $is_mailsite ?
                $this->mailUrl($data, ['arg' => 'editMailsite']) :
                $this->mailUrl($data,
                ['arg' => 'editAccount', 'account_id' => $aid]);
            $delete_url = $is_mailsite ? '' :
                $this->mailUrl($data,
                ['arg' => 'deleteAccount', 'account_id' => $aid]);
            $folder = $account['DEFAULT_FOLDER'] ?: 'INBOX';
            /* If a clone job is active and this row is its source
               account, paint the whole group light-green and
               disable the rename/delete handles so the user
               cannot edit the account mid-clone. The account
               name picks up a "(Cloning...)" link next to it that
               jumps to the clone status page. */
            $clone_job = $data['ACTIVE_CLONE_JOB'] ?? null;
            $is_cloning = $clone_job &&
                (int) $clone_job['SRC_ACCOUNT'] === $aid;
            $group_class = "user-accounts-group" .
                ($is_active ? " user-accounts-active" : "") .
                ($is_cloning ? " user-accounts-cloning" : "");
            $clone_status_url = $is_cloning ?
                $this->mailUrl($data, [
                'arg' => 'editAccount',
                'account_id' => $aid,
                'pane' => 'clone']) : '';
            if ($is_cloning) {
                /* Edit and delete are not safe while the clone
                   runs: deleting the source account would orphan
                   the in-progress job, and renaming changes the
                   IMAP login credentials the job is mid-use. */
                $edit_url = '';
                $delete_url = '';
            }
            /* an account is expanded when it is the active one, or
               when the session has no collapsed entry for it (the
               default), or when that entry is not collapsed. */
            $account_collapsed = $data['ACCOUNT_COLLAPSED'] ?? [];
            $collapsed = !$is_active &&
                !empty($account_collapsed[$aid]);
            $folders_class = "user-accounts-folders" .
                ($collapsed ? " user-accounts-folders-collapsed" :
                "");
            /* folders are considered already-loaded server-side
               when this is the active account (FOLDERS came back
               with the listMessages render) or when an earlier
               request cached them in the session; otherwise the
               JS will lazy-load on first expansion. */
            $cached_folders =
                $data['FOLDERS_BY_ACCOUNT'][$aid] ?? [];
            $folders_loaded = ($is_active && !empty($data['FOLDERS']))
                || !empty($cached_folders);
            /* the triangle reflects whether anything would be
               visible if the disclosure is "open": for a not-yet-
               loaded account the ul is empty regardless of the
               collapsed flag, so showing the expanded glyph would
               mislead. point at the account name (right, for
               ltr) until folders arrive. the glyphs come from the
               locale so rtl and top-bottom languages can pick
               appropriate shapes. */
            $triangle = ($collapsed || !$folders_loaded) ?
                tl('mail_element_folder_triangle_collapsed') :
                tl('mail_element_folder_triangle_expanded');
            $reorderable = (!$is_mailsite && !$is_cloning);
            e('<div class="' . $group_class);
            if ($reorderable) {
                e(' user-accounts-reorderable');
            }
            e('"');
            if ($reorderable) {
                e(' draggable="true" data-account-id="' .
                    $aid . '"');
            }
            e('>');
            if ($reorderable) {
                e('<span class="user-accounts-drag-handle ' .
                    'float-opposite" aria-hidden="true" title="' .
                    tl('mail_element_account_drag_label') .
                    '">&#8942;&#8942;</span>');
            }
            ?>
                <h3 class="user-accounts-name"
                    data-account-id="<?= $aid ?>"
                    data-account-display-name="<?=
                    $account['DISPLAY_NAME']
                    ?>"
                    data-edit-url="<?= $edit_url
                    ?>"
                    data-delete-url="<?=
                    $delete_url ?>"
                    data-delete-confirm="<?= tl(
                    'mail_element_delete_confirm') ?>"<?php
                    if ($is_cloning) {
                        e(' data-cloning="1"');
                    }
                    if ($is_mailsite) {
                        e(' data-no-rename="1"');
                    }
                    ?>>
                    <span class="user-accounts-toggle"
                        data-account-id="<?= $aid ?>"
                        data-collapsed="<?= $collapsed ? '1' : '0'
                        ?>"
                        data-folders-loaded="<?=
                        $folders_loaded ? '1' : '0' ?>"
                        data-toggle-url="<?=
                        $toggle_url ?>"
                        data-folders-url="<?=
                        $folders_url ?>"><span
                        class="user-accounts-triangle"><?=
                        $triangle ?></span>
                        <span class="user-accounts-display-name"><?=
                        $account['DISPLAY_NAME']
                        ?></span></span>
                    <?php
                    if ($is_cloning) {
                        /* The mail URLs in this element are already
                           entity-encoded (& separators), so
                           they are echoed raw; wrapping them in
                           htmlspecialchars would double-encode the
                           ampersands (&amp;) and the browser
                           would see garbage parameter names. The
                           parens+link sit in a single span so the
                           flex container in .user-accounts-name
                           treats them as one item (otherwise the
                           toggle-span and the parens-and-link
                           spread across the row via
                           justify-content: space-between). */
                        ?>
                        <span class="user-accounts-cloning-badge"
                            >(<a href="<?= $clone_status_url
                            ?>" class="user-accounts-cloning-link"
                            ><?= tl('mail_element_cloning_link')
                            ?></a>)</span>
                        <?php
                    }
                    ?>
                </h3>
                <ul class="<?= $folders_class ?>"
                    id="user-accounts-folders-<?= $aid ?>"
                    data-folder-expand-url="<?=
                    $folder_expand_url ?>"<?php
                    if ($is_cloning) {
                        e(' data-cloning="1"');
                    }
                    ?>>
                <?php
                if ($is_active && !empty($data['FOLDERS'])) {
                    $folders = $data['FOLDERS'];
                } else if (!empty($cached_folders)) {
                    $folders = $cached_folders;
                } else {
                    /* no real folder list yet — leave the ul empty
                       and let the JS lazy-load on first expansion.
                       see initMailAccountToggles in mailmessages.js
                       and userMailListFolders in SocialComponent. */
                    $folders = [];
                }
                $active_folder = $data['ACTIVE_FOLDER'] ?? $folder;
                self::renderFolderTree($data, $aid, $folders,
                    $is_active, $active_folder);
                ?>
                </ul>
            </div>
            <?php
        }
        ?>
        </div>
        <?php
    }
    /**
     * Renders the Scheduled pseudo-folder <li> for one account's
     * sidebar. Always emitted (even when the pending count is zero)
     * so the entry is discoverable before the user has ever used
     * the feature. The badge appears only when there are pending
     * messages; it reuses .user-accounts-folder-unread for visual
     * consistency with the unread counts on INBOX/Drafts/etc.
     *
     * Lives as a separate helper rather than inline in render()
     * so the AJAX userMailListFolders path can include the entry
     * in its rendered HTML. Without that, the JS lazy-load's
     * innerHTML replacement on the account's folder <ul> would
     * wipe the Scheduled entry the first time the user expanded
     * a previously-collapsed account.
     *
     * @param array $data fields including
     *      SCHEDULED_PENDING_BY_ACCOUNT, ACTIVE_FOLDER,
     *      CSRF_TOKEN
     * @param int $account_id which account's Scheduled entry to
     *      emit
     * @param bool $account_is_active whether this account is the
     *      one currently selected (drives the active highlight)
     */
    public static function renderScheduledFolderEntry($data,
        $account_id, $account_is_active)
    {
        $count = (int) (
            $data['SCHEDULED_PENDING_BY_ACCOUNT'][$account_id]
            ?? 0);
        $url = B\controllerUrl("user_mail", true) . C\p('CSRF_TOKEN') .
            "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=scheduledList&account_id=" . $account_id;
        $is_active = $account_is_active &&
            ($data['ACTIVE_FOLDER'] ?? '') === 'Scheduled';
        $row_class = "user-accounts-folder-row " .
            "user-accounts-folder-noselect" .
            ($is_active ? " user-accounts-folder-active" : "");
        ?>
        <li class="<?= $row_class ?>"><span
            class="user-accounts-folder-disclosure"
            aria-hidden="true">&#9656;</span><a
            href="<?= $url ?>"
            class="user-accounts-folder-label"><span
            class="user-accounts-folder-icon"
            aria-hidden="true">&#x1F551;</span><?=
            tl('mail_element_folder_scheduled') ?></a><?php
        if ($count > 0) { ?><span
            class="user-accounts-folder-unread"><?= $count
            ?></span><?php } ?></li>
        <?php
    }
    /**
     * Renders a flat folder list as a nested tree of <ul>/<li>,
     * grouping folders by the IMAP hierarchy delimiter that the
     * server reports on each LIST row. Children indent under
     * their parents; clicking a parent's disclosure arrow
     * (managed by the JS) toggles its children's visibility.
     *
     * Synthetic parent entries are emitted when a child folder
     * references a name segment that wasn't itself reported by
     * the server -- some Gmail-style namespaces only LIST the
     * leaf mailboxes like [Gmail]/Drafts without LISTing the
     * [Gmail] root. Those entries render as non-clickable
     * rows so they still anchor the tree visually.
     *
     * @param array $data $data with the CSRF token set
     * @param int $account_id the account these folders belong to
     * @param array $folders the flat parsed folder list
     * @param bool $account_is_active whether this account is the
     *      one currently selected
     * @param string $active_folder the folder currently in view
     */
    public static function renderFolderTree($data, $account_id,
        $folders, $account_is_active, $active_folder)
    {
        if (empty($folders)) {
            self::renderScheduledFolderEntry($data, $account_id,
                $account_is_active);
            return;
        }
        /* pick the dominant delimiter across this account's
           folders; per-row delimiters can in theory vary but
           in practice every folder in one namespace shares
           one. Default to '/' when nothing was reported. */
        $delimiter_counts = [];
        foreach ($folders as $folder_info) {
            $hierarchy = $folder_info['DELIMITER'] ?? '';
            if ($hierarchy !== '') {
                $delimiter_counts[$hierarchy] =
                    ($delimiter_counts[$hierarchy] ?? 0) + 1;
            }
        }
        $delimiter = '/';
        if (!empty($delimiter_counts)) {
            arsort($delimiter_counts);
            $delimiter = array_key_first($delimiter_counts);
        }
        $tree = self::buildFolderTree($folders, $delimiter);
        $rendered_scheduled = false;
        foreach ($tree as $node) {
            $is_fixed = strcasecmp($node['NAME'] ?? '',
                'INBOX') === 0 ||
                !empty($node['SPECIAL_USE']);
            if (!$rendered_scheduled && !$is_fixed) {
                self::renderScheduledFolderEntry($data,
                    $account_id, $account_is_active);
                $rendered_scheduled = true;
            }
            self::renderFolderTreeNode($data, $account_id,
                $node, $account_is_active, $active_folder,
                $delimiter);
        }
        if (!$rendered_scheduled) {
            self::renderScheduledFolderEntry($data, $account_id,
                $account_is_active);
        }
    }
    /**
     * Builds a nested-tree representation of a flat folder list:
     * each node is one folder entry with a CHILDREN key holding
     * its descendants. Synthetic parent entries are inserted when
     * a child folder references a path segment that wasn't itself
     * reported by the server (e.g. Gmail's [Gmail]/Drafts without
     * a [Gmail] root). Each level is ordered by
     * ImapFolderListParser::sortBySpecialUse so INBOX, Drafts,
     * Sent, etc. surface ahead of user folders at every depth.
     *
     * @param array $folders the flat parsed folder list
     * @param string $delimiter the hierarchy delimiter to split
     *      folder names on
     * @return array a list of top-level nodes, each with a
     *      CHILDREN list of the same shape
     */
    protected static function buildFolderTree($folders,
        $delimiter)
    {
        /* keyed by full path so synthetic-parent insertion can
           check both real folders and previously-synthesized
           ones via the same lookup. */
        $by_name = [];
        foreach ($folders as $folder_info) {
            $by_name[$folder_info['NAME']] = $folder_info;
        }
        $augmented = [];
        foreach ($folders as $folder_info) {
            $segments = explode($delimiter,
                $folder_info['NAME']);
            $prefix = '';
            for ($i = 0; $i < count($segments) - 1; $i++) {
                $prefix = ($i === 0) ? $segments[0] :
                    $prefix . $delimiter . $segments[$i];
                if (!isset($by_name[$prefix]) &&
                    !isset($augmented[$prefix])) {
                    $augmented[$prefix] = ['NAME' => $prefix,
                        'SELECTABLE' => false,
                        'SPECIAL_USE' => '',
                        'DELIMITER' => $delimiter,
                        'SYNTHETIC' => true];
                }
            }
            $augmented[$folder_info['NAME']] = $folder_info;
        }
        /* attach each non-root node to its parent's CHILDREN
           list; nodes with no slash in their name are roots. */
        $nodes = [];
        foreach ($augmented as $name => $folder_info) {
            $folder_info['CHILDREN'] = [];
            $nodes[$name] = $folder_info;
        }
        $roots = [];
        foreach ($nodes as $name => &$node) {
            $last_delim = strrpos($name, $delimiter);
            if ($last_delim === false) {
                $roots[] = &$node;
                continue;
            }
            $parent_name = substr($name, 0, $last_delim);
            if (isset($nodes[$parent_name])) {
                $nodes[$parent_name]['CHILDREN'][] = &$node;
            } else {
                /* orphan: parent path doesn't exist even after
                   synthesis. shouldn't happen, but if it does,
                   surface the node at the root rather than
                   silently dropping it. */
                $roots[] = &$node;
            }
        }
        unset($node);
        return self::sortTreeNodes($roots);
    }
    /**
     * Recursively applies the special-use sort at every level
     * of the tree: each siblings list is reordered by
     * ImapFolderListParser::sortBySpecialUse, and each node's
     * CHILDREN list is recursed into so deeper levels follow the
     * same convention.
     *
     * @param array $nodes a list of tree nodes (each with NAME,
     *      SPECIAL_USE, and CHILDREN keys)
     * @return array the same nodes in sorted order
     */
    protected static function sortTreeNodes($nodes)
    {
        $sorted = ML\ImapFolderListParser::sortBySpecialUse($nodes);
        foreach ($sorted as &$node) {
            if (!empty($node['CHILDREN'])) {
                $node['CHILDREN'] = self::sortTreeNodes(
                    $node['CHILDREN']);
            }
        }
        unset($node);
        return $sorted;
    }
    /**
     * Emits one node and recurses into its children. The node
     * opens an <li>; renderFolderItem writes the row content
     * (icon + name + disclosure arrow but no closing </li>);
     * children render inside a nested <ul>; then we close the
     * <li>.
     *
     * @param array $data $data with the CSRF token set
     * @param int $account_id the account this folder belongs to
     * @param array $node tree node with CHILDREN list
     * @param bool $account_is_active whether this account is the
     *      one currently selected
     * @param string $active_folder the folder currently in view
     * @param string $delimiter the hierarchy delimiter
     */
    protected static function renderFolderTreeNode($data,
        $account_id, $node, $account_is_active, $active_folder,
        $delimiter)
    {
        self::renderFolderItem($data, $account_id, $node,
            $account_is_active, $active_folder, $delimiter);
        if (!empty($node['CHILDREN'])) {
            echo
            '<ul class="user-accounts-folder-children">';
            foreach ($node['CHILDREN'] as $child) {
                self::renderFolderTreeNode($data, $account_id,
                    $child, $account_is_active, $active_folder,
                    $delimiter);
            }
            e('</ul>');
        }
        e('</li>');
    }
    /**
     * Renders one folder entry in an account's folder list. A
     * selectable folder is a link that opens that folder; a folder
     * carrying the \Noselect attribute is shown as plain text since
     * it cannot be opened. The folder currently being viewed (only
     * possible within the active account) gets the active class.
     *
     * @param array $data $data with the CSRF token set
     * @param int $account_id the id of the account this folder
     *      belongs to
     * @param array $folder_info one parsed folder with NAME,
     *      SELECTABLE, SPECIAL_USE, and optionally SYNTHETIC keys
     * @param bool $account_is_active whether this folder's account
     *      is the one currently selected
     * @param string $active_folder the name of the folder currently
     *      being viewed
     * @param string $delimiter the hierarchy delimiter, used to
     *      split the folder name into display segments
     */
    public static function renderFolderItem($data, $account_id,
        $folder_info, $account_is_active, $active_folder,
        $delimiter = '/')
    {
        $name = $folder_info['NAME'];
        $selectable = !empty($folder_info['SELECTABLE']);
        $is_synthetic = !empty($folder_info['SYNTHETIC']);
        $special = $folder_info['SPECIAL_USE'] ?? '';
        $is_active = $account_is_active && $name === $active_folder;
        /* Display values are HTML-cleaned in the controller
           (SocialComponent::userMailDecorateFolderDisplay) and
           echoed raw here. Real folders carry NAME_HTML /
           DELIMITER_HTML / DISPLAY_NAME directly; synthetic parent
           nodes inserted during tree building do not, so their
           label comes from the FOLDER_DISPLAY map (keyed by the
           raw full name) and their name/delimiter attributes fall
           back to the cleaned map / dominant delimiter. The raw
           $name is kept untouched for folder URLs and IMAP. */
        $display_map = $data['FOLDER_DISPLAY'] ?? [];
        $map_entry = $display_map[$name] ?? null;
        $name_html = $folder_info['NAME_HTML'] ??
            ($map_entry['NAME_HTML'] ?? $name);
        $delimiter_html = $folder_info['DELIMITER_HTML'] ??
            ($map_entry['DELIMITER_HTML'] ?? $delimiter);
        $display_name = $folder_info['DISPLAY_NAME'] ??
            ($map_entry['DISPLAY'] ?? $name);
        /* a folder renders expanded when (a) the user has
           previously expanded it (recorded in the session map),
           or (b) the active folder is a descendant of it (so
           opening a nested folder auto-reveals its ancestor
           chain). Otherwise it starts collapsed at page load. */
        $folder_expanded =
            $data['FOLDER_EXPANDED'][$account_id] ?? [];
        $is_expanded = !empty($folder_expanded[$name]);
        if (!$is_expanded && $account_is_active &&
            $active_folder !== '' && $active_folder !== $name) {
            $prefix = $name . $delimiter;
            if (strncmp($active_folder, $prefix,
                strlen($prefix)) === 0) {
                $is_expanded = true;
            }
        }
        $expanded_class = $is_expanded ? ' expanded' : '';
        if (!$selectable || $is_synthetic) {
            ?><li class="user-accounts-folder-row
                user-accounts-folder-noselect<?= $expanded_class
                ?>"
                data-account-id="<?= $account_id ?>"
                data-folder-name="<?= $name_html ?>"
                data-folder-delimiter="<?=
                $delimiter_html ?>"
                data-can-create-child="0"
                data-protected="1"><span
                class="user-accounts-folder-disclosure"
                aria-hidden="true">&#9656;</span><span
                class="user-accounts-folder-label"><span
                class="user-accounts-folder-icon">&#128193;</span> <?=
                $display_name ?></span><?php
            return;
        }
        $folder_url = self::mailUrl($data,
            ['arg' => 'listMessages', 'account_id' => $account_id,
            'folder' => $name]);
        $is_protected = ($special !== '') ||
            (strcasecmp($name, 'INBOX') === 0);
        /* distinct icons for the standard RFC 6154 special-use
           folders + INBOX, so the user can tell built-in folders
           apart from their own custom folders at a glance. any
           folder not in this table is treated as user-created
           and gets the generic folder glyph. */
        $icon = '&#128193;';
        if (strcasecmp($name, 'INBOX') === 0) {
            $icon = '&#128229;';
        } else if (strcasecmp($special, '\\Sent') === 0) {
            $icon = '&#128228;';
        } else if (strcasecmp($special, '\\Trash') === 0) {
            $icon = '&#128465;';
        } else if (strcasecmp($special, '\\Drafts') === 0) {
            $icon = '&#9999;';
        } else if (strcasecmp($special, '\\Junk') === 0) {
            $icon = '&#128683;';
        } else if (strcasecmp($special, '\\Archive') === 0) {
            $icon = '&#128451;';
        } else if (strcasecmp($special, '\\All') === 0) {
            $icon = '&#128218;';
        } else if (strcasecmp($special, '\\Flagged') === 0) {
            $icon = '&#11088;';
        }
        $unread = isset($folder_info['UNREAD']) ?
            (int) $folder_info['UNREAD'] : 0;
        ?><li class="user-accounts-folder-row<?= $expanded_class
            ?>"
            data-account-id="<?= $account_id ?>"
            data-folder-name="<?= $name_html ?>"
            data-folder-delimiter="<?=
            $delimiter_html ?>"
            data-can-create-child="1"
            data-protected="<?= $is_protected ? '1' : '0' ?>"><span
            class="user-accounts-folder-disclosure"
            aria-hidden="true">&#9656;</span><a href="<?=
            $folder_url ?>"<?= $is_active ?
            ' class="user-accounts-folder-active"' : '' ?>><span
            class="user-accounts-folder-icon"><?= $icon
            ?></span> <span
            class="user-accounts-folder-name"><?=
            $display_name ?></span><?php
        if ($unread > 0) {
            ?><span class="user-accounts-folder-unread"><?=
                $unread ?></span><?php
        }
        ?></a><?php
    }
    /**
     * Renders the account-messages content pane, dispatching on
     * $data['CONTENT_PANE'] to one of the five sub-renderers.
     *
     * @param array $data fields with CONTENT_PANE set
     */
    protected function renderAccountMessages($data)
    {
        $pane = $data['CONTENT_PANE'] ?? 'selectAccount';
        if ($pane === 'addAccount' || $pane === 'editAccount') {
            $this->renderAccountForm($data);
        } else if ($pane === 'editMailsite') {
            $this->renderMailsiteForm($data);
        } else if ($pane === 'inbox') {
            $this->renderInbox($data);
        } else if ($pane === 'message') {
            $this->renderMessage($data);
        } else if ($pane === 'compose') {
            $this->renderCompose($data);
        } else if ($pane === 'scheduledList') {
            $this->renderScheduledList($data);
        } else {
            $this->renderSelectAccount($data);
        }
    }
    /**
     * Renders the "select an account on the left" placeholder
     * shown in the content pane when no account is selected and
     * no form is active. Mirrors the user-messages-warning pattern
     * from UsermessagesElement.
     *
     * @param array $data fields including EXTERNAL_ENABLED, ACCOUNTS
     */
    protected function renderSelectAccount($data)
    {
        $external = !empty($data['EXTERNAL_ENABLED']);
        $has_accounts = !empty($data['ACCOUNTS']);
        $add_url = $this->mailUrl($data, ['arg' => 'addAccount']);
        if ($external && !$has_accounts) {
            ?>
            <div class="mail-empty-pane">
                <a class="button-box" href="<?= $add_url ?>"><?=
                    tl('mail_element_add_account') ?></a>
            </div>
            <?php
            return;
        }
        if ($external && $has_accounts) {
            ?>
            <div class="mail-empty-pane">
                <p class="user-mail-warning"><?=
                    tl('mail_element_select_account') ?></p>
                <p class="mail-empty-or"><?=
                    tl('mail_element_or') ?></p>
                <a class="button-box" href="<?= $add_url ?>"><?=
                    tl('mail_element_add_account') ?></a>
            </div>
            <?php
            return;
        }
        ?>
        <p class="user-mail-warning"><?=
            tl('mail_element_select_account') ?></p>
        <?php
    }
    /**
     * Renders the MailSite account properties pane, reached from the
     * edit pencil on the local MailSite account row. Its one section
     * is alias management: a list of the user's current aliases each
     * with a remove control, and an add form pairing a local-part
     * field with a dropdown of the configured mail domains. Aliases
     * are stored as a local-part that is valid at every configured
     * domain; the dropdown only chooses which domain to show in the
     * confirmation, since the stored alias is domain-independent.
     *
     * @param array $data fields including ALIASES (the user's alias
     *      local-parts), MAIL_DOMAINS_LIST (configured domains),
     *      optionally ALIAS_ERROR, and the CSRF token
     */
    protected function renderMailsiteForm($data)
    {
        $back_url = $this->mailUrl($data);
        $submit_url = $this->mailUrl($data,
            ['arg' => 'aliasAction']);
        $aliases = $data['ALIASES'] ?? [];
        $domains = $data['MAIL_DOMAINS_LIST'] ?? [];
        $error = $data['ALIAS_ERROR'] ?? '';
        $heading = $data['MAILSITE_DISPLAY_NAME'] ?? '';
        if ($heading === '') {
            $heading = tl('mail_element_mailsite_heading');
        }
        ?>
        <?= $this->view->helper("close")->render($back_url) ?>
        <h2><?= $heading ?></h2>
        <h3><?= tl('mail_element_alias_heading') ?></h3>
        <p><?= tl('mail_element_alias_explain') ?></p>
        <?php
        if ($error !== '') {
            ?>
            <p class="red"><?= $error ?></p>
            <?php
        }
        if (empty($aliases)) {
            ?>
            <p class="mail-alias-empty"><?=
                tl('mail_element_alias_none') ?></p>
            <?php
        } else {
            ?>
            <div class="mail-alias-list">
            <table class="mail-alias-table">
            <?php
            foreach ($aliases as $alias) {
                $remove_url = $this->mailUrl($data, [
                    'arg' => 'aliasAction',
                    'alias_action' => 'remove',
                    'alias' => $alias['ALIAS'],
                    'alias_domain' => $alias['DOMAIN']]);
                ?>
                <tr>
                    <td class="mail-alias-name"><?= $alias['ADDRESS']
                        ?></td>
                    <td><a class="gray-link"
                        href="<?= $remove_url ?>"><?=
                        tl('mail_element_alias_remove') ?></a></td>
                </tr>
                <?php
            }
            ?>
            </table>
            </div>
            <?php
        }
        ?>
        <form method="post" action="<?= $submit_url ?>"
            class="top-margin mail-alias-form">
            <input type="hidden" name="<?= C\p('CSRF_TOKEN') ?>"
                value="<?= $data[C\p('CSRF_TOKEN')] ?>">
            <input type="hidden" name="alias_action" value="add">
            <input type="text" name="alias"
                class="mail-alias-input"
                placeholder="<?= tl(
                'mail_element_alias_placeholder') ?>"
                aria-label="<?= tl(
                'mail_element_alias_placeholder') ?>">
            <span class="mail-alias-at">@</span>
            <select name="alias_domain"
                class="mail-alias-domain"><?php
                foreach ($domains as $domain) {
                    ?>
                    <option value="<?= $domain ?>"><?= $domain
                        ?></option><?php
                }
                ?></select>
            <button type="submit" class="button-box mail-alias-add"
                title="<?= tl('mail_element_alias_add') ?>"
                aria-label="<?= tl('mail_element_alias_add')
                ?>">+</button>
        </form>
        <?php
    }
    /**
     * Renders the Add Account or Edit Account form. The two share
     * a single template; $data['CONTENT_PANE'] selects which
     * submit URL gets built and whether the password field shows
     * a "leave blank" placeholder.
     *
     * @param array $data fields including FORM_VALUES, optionally
     *      FORM_ERRORS, ACCOUNT_ID (for edit)
     */
    protected function renderAccountForm($data)
    {
        $is_edit = ($data['CONTENT_PANE'] === 'editAccount');
        $values = $data['FORM_VALUES'] ?? [];
        $errors = $data['FORM_ERRORS'] ?? [];
        $submit_extra = ['arg' => $is_edit ? 'editAccount' : 'addAccount',
            'save' => 'save'];
        if ($is_edit) {
            $submit_extra['account_id'] = $data['ACCOUNT_ID'];
        }
        $submit_url = $this->mailUrl($data, $submit_extra);
        $back_url = $this->mailUrl($data);
        $show_clone = $is_edit && !empty($data['MAILSITE_ENABLED']);
        $active_job = $data['ACTIVE_CLONE_JOB'] ?? null;
        $failed_job = $data['FAILED_CLONE_JOB'] ?? null;
        $banner_job = $active_job ?: $failed_job;
        $clone_pane = $show_clone &&
            !empty($data['CLONE_PANE_ACTIVE']);
        if ($show_clone && $banner_job) {
            /* Active clone in flight, or a just-failed clone the
               user can retry: replace the whole edit pane with the
               status banner so an in-flight job cannot be doubled
               and a failed one stays visible with its Retry action
               and error list rather than vanishing. */
            ?>
            <?= $this->view->helper("close")->render($back_url) ?>
            <?php
            $this->renderCloneStatusBanner($data, $banner_job);
            return;
        }
        if ($clone_pane) {
            /* User clicked the Clone-to-Local-Mailbox link below
               the Name field. Render only the clone form, with a
               closeHelper [x] at the top-right that navigates back
               to the bare Edit Mail Account URL (no pane=clone),
               which re-renders the settings form. */
            $edit_url = $this->mailUrl($data, [
                'arg' => 'editAccount',
                'account_id' => $data['ACCOUNT_ID']]);
            ?>
            <?= $this->view->helper("close")->render($edit_url) ?>
            <?php
            $this->renderCloneForm($data);
            return;
        }
        ?>
        <?= $this->view->helper("close")->render($back_url) ?>
        <h2><?= $is_edit ? tl('mail_element_edit_account_heading') :
            tl('mail_element_add_account_heading') ?>
        <?= $this->view->helper("helpbutton")->render(
            "Mail Account", $data[C\p('CSRF_TOKEN')]) ?>
        </h2>
        <form method="post" action="<?= $submit_url ?>"
            class="top-margin mail-account-form">
        <table>
        <tr><th class="table-label">
            <label for="ma-provider"><?=
                tl('mail_element_field_provider') ?>:</label>
        </th><td>
            <?php
            $current_provider = $values['PROVIDER'] ?? '';
            ?>
            <select id="ma-provider" name="provider"
                data-username-text="<?= tl(
                    'mail_element_field_username') ?>"
                data-email-text="<?= tl(
                    'mail_element_field_email') ?>">
            <option value=""<?= $current_provider === '' ?
                ' selected' : '' ?>><?=
                tl('mail_element_provider_custom') ?></option>
            <?php
            foreach (MailAccountModel::PROVIDER_PRESETS
                    as $name => $preset) {
                $selected = ($current_provider === $name) ?
                    ' selected' : '';
                ?><option value="<?= $name
                    ?>"<?= $selected ?>
                    data-imap-host="<?= $preset['imap_host'] ?>"
                    data-imap-port="<?= $preset['imap_port'] ?>"
                    data-imap-tls="<?= $preset['imap_tls'] ?>"
                    data-smtp-host="<?= $preset['smtp_host'] ?>"
                    data-smtp-port="<?= $preset['smtp_port'] ?>"
                    data-smtp-tls="<?= $preset['smtp_tls'] ?>"
                    data-email-domain="<?=
                    (string) ($preset['email_domain'] ?? '') ?>"
                    data-login-label="<?=
                    $preset['login_label'] ?>"><?=
                    $name ?></option><?php
            }
            ?>
            </select>
        </td></tr>
        <tr><th class="table-label">
            <label for="ma-display-name"><?=
                tl('mail_element_field_display_name') ?>:</label>
        </th><td>
            <input id="ma-display-name" type="text"
                name="display_name"
                value="<?= $values['DISPLAY_NAME'] ?? '' ?>"
                maxlength="80" class="narrow-field" >
            <?= $this->renderFieldError($errors, 'DISPLAY_NAME') ?>
        </td></tr>
        <?php
        if ($show_clone) {
            $clone_url = $this->mailUrl($data, [
                'arg' => 'editAccount',
                'account_id' => $data['ACCOUNT_ID'],
                'pane' => 'clone']);
            ?>
            <tr><th class="table-label"></th><td>
                <a href="<?= $clone_url ?>"
                    class="mail-clone-open-link"><?=
                    tl('mail_element_clone_open_link') ?></a>
            </td></tr>
            <?php
        }
        $preset_hidden_class = $current_provider !== '' ?
            ' none' : '';
        ?>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th colspan="2" class="mail-section-label"><?=
            tl('mail_element_imap_section') ?></th></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-host"><?=
                tl('mail_element_field_host') ?>:</label>
        </th><td>
            <input id="ma-host" type="text" name="host"
                value="<?= $values['HOST'] ?? '' ?>"
                maxlength="255" class="narrow-field" >
            <?= $this->renderFieldError($errors, 'HOST') ?>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-port"><?=
                tl('mail_element_field_port') ?>:</label>
        </th><td>
            <input id="ma-port" type="number" name="port"
                value="<?= (int) ($values['PORT'] ?? 993) ?>"
                min="1" max="65535" >
            <?= $this->renderFieldError($errors, 'PORT') ?>
        </td></tr>
        <tr><th class="table-label">
            <label for="ma-username" id="ma-username-label"><?php
            $login_label = 'username';
            $preset = MailAccountModel::PROVIDER_PRESETS[
                $current_provider] ?? null;
            if ($preset && isset($preset['login_label'])) {
                $login_label = $preset['login_label'];
            }
            e($login_label === 'email' ?
                tl('mail_element_field_email') :
                tl('mail_element_field_username'));
            ?>:</label>
        </th><td>
            <input id="ma-username" type="text" name="username"
                value="<?= $values['USERNAME'] ?? '' ?>"
                maxlength="255" class="narrow-field" >
            <?= $this->renderFieldError($errors, 'USERNAME') ?>
        </td></tr>
        <tr><th class="table-label">
            <label for="ma-password"><?=
                tl('mail_element_field_password') ?>:</label>
        </th><td>
            <input id="ma-password" type="password" name="password"
                value="<?= $values['PASSWORD'] ?? '' ?>"
                autocomplete="new-password"
                class="narrow-field" >
            <?= $this->renderFieldError($errors, 'PASSWORD') ?>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-tls-mode"><?=
                tl('mail_element_field_tls_mode') ?>:</label>
        </th><td>
            <select id="ma-tls-mode" name="tls_mode">
            <?php
            $tls = $values['TLS_MODE'] ?? 'imaps';
            foreach (['imaps' => tl('mail_element_imaps'),
                'starttls' => tl('mail_element_starttls'),
                'plain' => tl('mail_element_plain')] as $key =>
                    $label) {
                $sel = ($tls === $key) ?
                    ' selected="selected"' : '';
                ?><option value="<?= $key ?>"<?= $sel ?>><?=
                    $label ?></option><?php
            }
            ?>
            </select>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th colspan="2" class="mail-section-label"><?=
            tl('mail_element_smtp_section') ?></th></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-smtp-host"><?=
                tl('mail_element_field_host') ?>:</label>
        </th><td>
            <input id="ma-smtp-host" type="text" name="smtp_host"
                value="<?= $values['SMTP_HOST'] ?? '' ?>"
                maxlength="255" class="narrow-field" >
            <?= $this->renderFieldError($errors, 'SMTP_HOST') ?>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-smtp-port"><?=
                tl('mail_element_field_port') ?>:</label>
        </th><td>
            <input id="ma-smtp-port" type="number" name="smtp_port"
                value="<?= (int) ($values['SMTP_PORT'] ?? 587) ?>"
                min="1" max="65535" >
            <?= $this->renderFieldError($errors, 'SMTP_PORT') ?>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-smtp-username"><?=
                tl('mail_element_field_username') ?>:</label>
        </th><td>
            <input id="ma-smtp-username" type="text"
                name="smtp_username"
                value="<?= $values['SMTP_USERNAME'] ?? '' ?>"
                maxlength="255" class="narrow-field" >
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-smtp-password"><?=
                tl('mail_element_field_password') ?>:</label>
        </th><td>
            <input id="ma-smtp-password" type="password"
                name="smtp_password"
                value="<?= $values['SMTP_PASSWORD'] ?? '' ?>"
                autocomplete="new-password"
                class="narrow-field" >
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-smtp-tls-mode"><?=
                tl('mail_element_field_tls_mode') ?>:</label>
        </th><td>
            <select id="ma-smtp-tls-mode" name="smtp_tls_mode">
            <?php
            $smtp_tls = $values['SMTP_TLS_MODE'] ?? 'starttls';
            foreach (['smtps' => tl('mail_element_smtps'),
                'starttls' => tl('mail_element_starttls'),
                'plain' => tl('mail_element_plain')] as $key =>
                    $label) {
                $sel = ($smtp_tls === $key) ?
                    ' selected="selected"' : '';
                ?><option value="<?= $key ?>"<?= $sel ?>><?=
                    $label ?></option><?php
            }
            ?>
            </select>
        </td></tr>
        <tr class="mail-account-preset-field<?= $preset_hidden_class
            ?>"><th class="table-label">
            <label for="ma-allow-self-signed"><?=
                tl('mail_element_field_allow_self_signed') ?>
                <span class="mail-form-label-scope"><?=
                tl('mail_element_field_allow_self_signed_scope')
                ?>:</span></label>
        </th><td>
            <input id="ma-allow-self-signed" type="checkbox"
                name="allow_self_signed" value="1"
                <?= empty($values['ALLOW_SELF_SIGNED']) ?
                    '' : 'checked="checked"' ?> >
        </td></tr>
        <tr><td colspan="2">
            <div class="mail-form-actions">
                <button type="submit" class="button-box"><?=
                    tl('mail_element_save') ?></button>
                <a class="button-box" href="<?= $back_url ?>"><?=
                    tl('mail_element_cancel') ?></a>
            </div>
        </td></tr>
        </table>
        </form>
        <?php
    }
    /**
     * Renders the clone-submission form as a full replacement of
     * the settings form on the Edit Account page. Reached by
     * clicking the "Clone to Local Mailbox" link below the Name
     * field, which sets pane=clone on the editAccount URL; the
     * renderAccountForm dispatcher detects that flag and routes
     * here instead of rendering the settings form. The
     * closeHelper [x] at the top-right (rendered by the caller)
     * navigates back to the bare editAccount URL, which
     * re-renders the settings form.
     *
     * Renders one of two inner states:
     *   - MailClone media job disabled in Configure Media Jobs:
     *     explanatory paragraph + disabled button so the user
     *     understands why submission is unavailable and what
     *     admin action would unblock it.
     *   - Otherwise: the active form with three mutually-
     *     exclusive mode radios (add / wipe / dry-run),
     *     destination user (read-only, fixed to current Yioop
     *     username), Start Clone button, and a close [x]
     *     button.
     *
     * @param array $data view data
     */
    protected function renderCloneForm($data)
    {
        $enabled = !empty($data['MAILCLONE_ENABLED']);
        $back_url = $this->mailUrl($data, [
            'arg' => 'editAccount',
            'account_id' => $data['ACCOUNT_ID']]);
        $username = $data['USER_NAME'] ?? '';
        ?>
        <h2><?= tl('mail_element_clone_heading',
            $username) ?>
            <?= $this->view->helper("helpbutton")->render(
                "Mail Clone", $data[C\p('CSRF_TOKEN')]) ?>
        </h2>
        <p><?= tl('mail_element_clone_explain') ?></p>
        <?php
        if (!$enabled) {
            ?>
            <p><?= tl('mail_element_clone_disabled_explain')
                ?></p>
            <form class="top-margin mail-account-form">
            <div class="mail-form-actions">
                <button type="button" class="button-box" disabled
                    title="<?= tl(
                        'mail_element_clone_disabled_tooltip')
                    ?>"><?= tl('mail_element_clone_button')
                    ?></button>
                <a class="button-box" href="<?= $back_url ?>"><?=
                    tl('mail_element_cancel') ?></a>
            </div>
            </form>
            <?php
            return;
        }
        $submit_url = $this->mailUrl($data, [
            'arg' => 'startClone',
            'account_id' => $data['ACCOUNT_ID']]);
        $confirm_wipe = tl(
            'mail_element_clone_wipe_confirm_prompt');
        ?>
        <form method="post" action="<?= $submit_url ?>"
            class="top-margin mail-account-form mail-clone-form"
            data-confirm-wipe="<?=
                $confirm_wipe ?>">
        <table>
        <tr><th class="table-label">
            <label><?= tl('mail_element_clone_mode_label')
                ?>:</label>
        </th><td>
            <label>
                <input type="radio" name="clone_mode"
                    value="add" checked="checked" >
                <?= tl('mail_element_clone_mode_add') ?>
            </label>
            <br>
            <label>
                <input type="radio" name="clone_mode"
                    value="wipe" >
                <?= tl('mail_element_clone_mode_wipe') ?>
            </label>
            <br>
            <label>
                <input type="radio" name="clone_mode"
                    value="dry_run" >
                <?= tl('mail_element_clone_mode_dry_run') ?>
            </label>
        </td></tr>
        <tr><th class="table-label">
            <label><?= tl('mail_element_clone_cap_label')
                ?>:</label>
        </th><td>
            <select name="folder_cap"
                class="mail-clone-folder-cap">
                <option value="0" selected="selected">ALL</option>
                <option value="1000">1000</option>
                <option value="5000">5000</option>
                <option value="10000">10000</option>
                <option value="50000">50000</option>
            </select>
        </td></tr>
        <tr><td colspan="2">
            <div class="mail-form-actions">
                <button type="submit" class="button-box"><?=
                    tl('mail_element_clone_button') ?></button>
                <a class="button-box" href="<?= $back_url ?>"><?=
                    tl('mail_element_cancel') ?></a>
            </div>
        </td></tr>
        </table>
        </form>
        <?php
    }
    /**
     * Renders the in-progress / done / failed status banner
     * for an active clone job. Polled in the background by the
     * mailclone.js script (5-second interval). Display values
     * (folder name, status) are cleaned in the controller and
     * echoed raw here; counters are cast to int.
     *
     * @param array $data view data (CSRF token, URL helpers)
     * @param array $job the active MAIL_CLONE_JOB row
     */
    protected function renderCloneStatusBanner($data, $job)
    {
        $cancel_url = $this->mailUrl($data, [
            'arg' => 'cancelClone',
            'job_id' => (int) $job['ID']]);
        $retry_url = $this->mailUrl($data, [
            'arg' => 'retryClone',
            'job_id' => (int) $job['ID']]);
        $status_url = $this->mailUrl($data,
            ['arg' => 'cloneStatus']);
        $status_token = $job['STATUS'];
        $username = $data['USER_NAME'] ?? '';
        ?>
        <h2><?= tl('mail_element_clone_heading',
            $username) ?>
            <?= $this->view->helper("helpbutton")->render(
                "Mail Clone", $data[C\p('CSRF_TOKEN')]) ?>
        </h2>
        <?php
        /* Persistent red warning beneath the heading when
           either daemon is down -- this is not a flash, it
           stays visible the entire time the user is on the
           page. mailclone.js refreshes the same warning at
           each status-poll interval by toggling the .none
           class so the warning reappears if MediaUpdater or
           MailServer crashes mid-clone. */
        $updater_down = empty($data['MEDIA_UPDATER_RUNNING']);
        $server_down = empty($data['MAIL_SERVER_RUNNING']);
        ?>
        <p class="red mail-clone-warn-updater<?=
            $updater_down ? '' : ' none' ?>">
            <?= tl('mail_element_clone_updater_warn') ?>
        </p>
        <p class="red mail-clone-warn-server<?=
            $server_down ? '' : ' none' ?>">
            <?= tl('mail_element_clone_server_warn') ?>
        </p>
        <?php
        /* Direct link to the admin Manage Machines page so the
           user can start MediaUpdater / MailServer when either is
           down. Shown whenever a daemon is down; mailclone.js
           toggles the surrounding warnings on each status poll,
           and this link sits with them so the path to fix it is
           one click away. */
        $machines_url = htmlentities(B\controllerUrl("admin",
            true)) . C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&a=manageMachines";
        ?>
        <p class="mail-clone-manage-machines<?=
            ($updater_down || $server_down) ? '' : ' none' ?>">
            <a href="<?= $machines_url ?>"><?=
                tl('mail_element_clone_manage_machines') ?></a>
        </p>
        <div class="top-margin mail-account-form mail-clone-status"
            data-job-id="<?= (int) $job['ID'] ?>"
            data-poll-url="<?= $status_url ?>">
        <table>
        <tr><th class="table-label">
            <?= tl('mail_element_clone_status_label') ?>
        </th><td>
            <span class="mail-clone-status-text"><?=
                $status_token ?></span>
        </td></tr>
        <tr><th class="table-label">
            <?= tl('mail_element_clone_status_folder') ?>:
        </th><td>
            <span class="mail-clone-current-folder"><?=
                ($job['CURRENT_FOLDER'] ?: '-') ?></span>
        </td></tr>
        <tr><th class="table-label">
            <?= tl('mail_element_clone_status_imported') ?>:
        </th><td>
            <span class="mail-clone-imported"><?=
                (int) $job['IMPORTED'] ?></span>
        </td></tr>
        <tr><th class="table-label">
            <?= tl('mail_element_clone_status_skipped') ?>:
        </th><td>
            <span class="mail-clone-skipped"><?=
                (int) $job['SKIPPED'] ?></span>
        </td></tr>
        <tr><th class="table-label">
            <?= tl('mail_element_clone_status_failed') ?>:
        </th><td>
            <?php $has_failures = ((int) $job['FAILED']) > 0; ?>
            <a href="#" class="mail-clone-failed-link<?=
                $has_failures ? '' : ' none' ?>"><?=
                (int) $job['FAILED'] ?></a>
            <span class="mail-clone-failed-plain<?=
                $has_failures ? ' none' : '' ?>"><?=
                (int) $job['FAILED'] ?></span>
        </td></tr>
        </table>
        <div class="mail-clone-errors-row none">
            <div class="mail-clone-errors"
                data-resolved-via-template="<?=
                $data['CLONE_ERROR_RESOLVED_VIA_TEMPLATE'] ?? ''
                ?>"></div>
        </div>
        <?php
        if (in_array($status_token,
                ['pending', 'running'], true)) {
            ?>
            <div class="mail-form-actions">
                <form method="post"
                    action="<?= $cancel_url ?>"
                    class="mail-clone-cancel-form">
                    <button type="submit"
                        class="button-box"><?= tl(
                        'mail_element_clone_cancel')
                        ?></button>
                </form>
            </div>
            <?php
        } else if ($status_token === 'failed') {
            ?>
            <div class="mail-form-actions">
                <form method="post"
                    action="<?= $retry_url ?>"
                    class="mail-clone-retry-form">
                    <button type="submit"
                        class="button-box"><?= tl(
                        'mail_element_clone_retry')
                        ?></button>
                </form>
            </div>
            <?php
        }
        ?>
        </div>
        <?php
    }
    /**
     * Renders a small inline error message for a form field; emits
     * nothing when the field has no error.
     *
     * @param array $errors map of field name => message
     * @param string $field name of the field
     * @return string the rendered span, or empty string
     */
    protected function renderFieldError($errors, $field)
    {
        if (empty($errors[$field])) {
            return '';
        }
        return ' <span class="red">' .
            $errors[$field] . '</span>';
    }
    /**
     * Renders the inbox listing in the content pane: account name
     * in the header, then a bounded, scrollable list of envelopes
     * (Subject, From, Date) with each subject linking to the message
     * view. The scroll container carries the data- attributes the
     * mailmessages.js infinite-scroll handler reads to fetch older
     * batches. Surfaces IMAP errors inline.
     *
     * @param array $data fields including ACCOUNT_ID,
     *      ACCOUNT_DISPLAY_NAME, MESSAGES, TOTAL_MESSAGES,
     *      OLDEST_SEQ, IMAP_ERROR (when set)
     */
    protected function renderInbox($data)
    {
        $active_folder = $data['ACTIVE_FOLDER'] ?? '';
        $unread_only = !empty($data['UNREAD_ONLY']);
        $flagged_only = !empty($data['FLAGGED_ONLY']);
        $refresh_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        if ($active_folder !== '') {
            $refresh_args['folder'] = $active_folder;
        }
        if ($unread_only) {
            $refresh_args['unread_only'] = '1';
        }
        if ($flagged_only) {
            $refresh_args['flagged_only'] = '1';
        }
        $refresh_url = $this->mailUrl($data, $refresh_args);
        $compose_url = $this->mailUrl($data,
            ['arg' => 'compose',
            'account_id' => $data['ACCOUNT_ID']]);
        $back_url = $this->mailUrl($data, ['deselect' => '1']);
        $sort = $data['SORT'] ?? ['key' => 'date', 'reverse' => false];
        $filter = $data['FILTER'] ?? '';
        /* toggle URL inverts the current unread-only flag: when
           currently off, the link turns it on, and vice versa.
           folder, sort, and filter are preserved so a deliberate
           narrowing survives the toggle. */
        $unread_toggle_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        if ($active_folder !== '') {
            $unread_toggle_args['folder'] = $active_folder;
        }
        if (!$unread_only) {
            $unread_toggle_args['unread_only'] = '1';
        }
        if ($flagged_only) {
            $unread_toggle_args['flagged_only'] = '1';
        }
        if ($sort['key'] !== 'date') {
            $unread_toggle_args['sort_key'] = $sort['key'];
        }
        if (!empty($sort['reverse'])) {
            $unread_toggle_args['sort_reverse'] = '1';
        }
        if ($filter !== '') {
            $unread_toggle_args['filter'] = $filter;
        }
        $unread_toggle_url = $this->mailUrl($data,
            $unread_toggle_args);
        /* parallel flagged-only toggle URL; same shape, inverts
           the flagged_only bit, preserves everything else. */
        $flagged_toggle_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        if ($active_folder !== '') {
            $flagged_toggle_args['folder'] = $active_folder;
        }
        if ($unread_only) {
            $flagged_toggle_args['unread_only'] = '1';
        }
        if (!$flagged_only) {
            $flagged_toggle_args['flagged_only'] = '1';
        }
        if ($sort['key'] !== 'date') {
            $flagged_toggle_args['sort_key'] = $sort['key'];
        }
        if (!empty($sort['reverse'])) {
            $flagged_toggle_args['sort_reverse'] = '1';
        }
        if ($filter !== '') {
            $flagged_toggle_args['filter'] = $filter;
        }
        $flagged_toggle_url = $this->mailUrl($data,
            $flagged_toggle_args);
        $icon_helper = $this->view->helper('iconlink');
        /* mail-refresh-link id stays on the refresh anchor for the
           auto-refresh JS hook; the [$id, $url] form of renderButton
           threads the id through onto the wrapping div, and the
           anchor that elt() finds is the only one inside. */
        $refresh_link = $icon_helper->renderButton(
            ['mail-refresh-link', $refresh_url], 'refresh', '',
            true);
        $compose_link = $icon_helper->renderButton($compose_url,
            'compose', '', true);
        $unread_toggle_link = $icon_helper->renderButton(
            $unread_toggle_url,
            $unread_only ? 'mail_filter_all' :
            'mail_filter_unread', '', true);
        $back_link = '<span class="mail-back-mobile">' .
            $icon_helper->renderButton($back_url, 'back', '',
            true) . '</span>';
        if (!empty($data['IMAP_ERROR'])) {
            ?>
            <h3><?= $back_link ?> <?=
                $data['ACCOUNT_DISPLAY_NAME']
                ?> <?= $refresh_link
                ?> <?= $unread_toggle_link
                ?> <?= $compose_link ?></h3>
            <p class="red"><?=
                $data['IMAP_ERROR'] ?></p>
            <?php
            return;
        }
        $messages = $data['MESSAGES'] ?? [];
        /* filter URL preserves current sort so debounce-fire
           navigations from the filter input do not lose the
           user's column sort. folder is included so the filter
           stays scoped to the active folder. */
        $filter_url_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        if ($active_folder !== '') {
            $filter_url_args['folder'] = $active_folder;
        }
        if ($sort['key'] !== 'date') {
            $filter_url_args['sort_key'] = $sort['key'];
        }
        if (!empty($sort['reverse'])) {
            $filter_url_args['sort_reverse'] = '1';
        }
        if (!empty($data['UNREAD_ONLY'])) {
            $filter_url_args['unread_only'] = '1';
        }
        if (!empty($data['FLAGGED_ONLY'])) {
            $filter_url_args['flagged_only'] = '1';
        }
        $filter_url = $this->mailUrl($data, $filter_url_args);
        /* the filter input markup is the same in the empty and
           the populated paths; render it via a closure so the
           two branches stay in sync. $filter holds the cleaned
           display value (from $data['FILTER']); it is captured
           explicitly because a closure does not otherwise see
           $data. */
        $filter_input = function () use ($filter, $filter_url) {
            e('<span class="mail-filter-wrap">' .
                '<span class="mail-filter-icon">&#x1F50D;</span>' .
                '<input type="text" class="mail-filter-input" ' .
                'id="mail-filter-input" data-filter-url="' .
                $filter_url . '" placeholder="' .
                tl('mail_element_filter_placeholder') .
                '" value="' . $filter . '"');
            if ($filter !== '') {
                e(' autofocus');
            }
            e('>');
            if ($filter !== '') {
                e('<span class="mail-filter-clear" ' .
                    'id="mail-filter-clear" title="' .
                    tl('mail_element_filter_clear') .
                    '">&times;</span>');
            }
            e('</span>');
        };
        $total = (int) ($data['TOTAL_MESSAGES'] ?? 0);
        if (empty($messages)) {
            ?>
            <h3 class="mail-inbox-heading">
                <span class="mail-inbox-heading-title"><?=
                    $back_link ?> <?=
                    $data['ACCOUNT_DISPLAY_NAME'] ?>
                    <span class="mail-inbox-heading-count">(<?=
                    $total ?>)</span> <?= $refresh_link
                    ?> <?= $unread_toggle_link
                    ?> <?= $compose_link ?></span>
                <?php $filter_input(); ?>
            </h3>
            <p class="mail-inbox-empty-text gray-text"><?=
                $filter !== '' ?
                tl('mail_element_no_filter_matches') :
                tl('mail_element_empty_inbox') ?></p>
            <?php
            return;
        }
        $load_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=loadMessages";
        if ($active_folder !== '') {
            $load_url .= "&folder=" . urlencode($active_folder);
        }
        $sort_supported = !empty($data['SORT_SUPPORTED']);
        $sort_date_only = !empty($data['SORT_DATE_ONLY']);
        $sort_mode = $data['SORT_MODE'] ?? 'range';
        $sort_offset = (int) ($data['SORT_OFFSET'] ?? 0);
        $load_url .= "&mode=" . urlencode($sort_mode);
        if (($sort['key'] ?? 'date') !== 'date' ||
            !empty($sort['reverse'])) {
            $load_url .= "&sort_key=" .
                urlencode($sort['key']);
            if (!empty($sort['reverse'])) {
                $load_url .= "&sort_reverse=1";
            }
        }
        if ($filter !== '') {
            $load_url .= "&filter=" . urlencode($filter);
        }
        if ($unread_only) {
            $load_url .= "&unread_only=1";
        }
        if ($flagged_only) {
            $load_url .= "&flagged_only=1";
        }
        /* a tiny closure that builds the click-to-sort URL for one
           column: if the column is already active, the click flips
           the reverse flag; otherwise it switches the active sort
           to this column with reverse cleared. filter is carried
           through so re-sorting does not lose the filter. */
        $sort_link_url = function ($key) use ($data, $active_folder,
            $sort, $filter, $unread_only, $flagged_only) {
            $args = ['arg' => 'listMessages',
                'account_id' => $data['ACCOUNT_ID'],
                'sort_key' => $key];
            if ($active_folder !== '') {
                $args['folder'] = $active_folder;
            }
            if ($sort['key'] === $key && empty($sort['reverse'])) {
                $args['sort_reverse'] = '1';
            }
            if ($filter !== '') {
                $args['filter'] = $filter;
            }
            if ($unread_only) {
                $args['unread_only'] = '1';
            }
            if ($flagged_only) {
                $args['flagged_only'] = '1';
            }
            return $this->mailUrl($data, $args);
        };
        $subject_url = $sort_link_url('subject');
        $from_url = $sort_link_url('from');
        $date_url = $sort_link_url('date');
        $bulk_url = $this->mailUrl($data, [
            'arg' => 'bulkAction',
            'account_id' => $data['ACCOUNT_ID'],
            'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
        ]);
        $folders = $data['FOLDERS'] ?? [];
        $active = $data['ACTIVE_FOLDER'] ?? '';
        $accounts = $data['ACCOUNTS'] ?? [];
        $folders_by_account = $data['FOLDERS_BY_ACCOUNT'] ?? [];
        $current_account_id = (int) $data['ACCOUNT_ID'];
        /* Selectable folder names per account for the move
           destination, consumed by the inbox script to repopulate
           the folder list when the account selector changes. The
           NAME_HTML values were cleaned in the controller. The
           encoded JSON is htmlspecialchars-escaped here only
           because it is being embedded inside a double-quoted HTML
           attribute, where a raw JSON double quote would otherwise
           close the attribute early; the browser decodes it back
           to valid JSON before the script reads it. */
        $move_folder_options = [];
        foreach ($accounts as $account_row) {
            $aid = (int) ($account_row['ID'] ?? -1);
            $rows = $folders_by_account[$aid] ?? [];
            /* the currently viewed account always has its folder
               list in $data['FOLDERS'] (the inbox handler just
               fetched it); use that when the per-account cache has
               not been populated for it yet, so the move
               destination list is never empty for the account in
               view. */
            if (empty($rows) && $aid === $current_account_id) {
                $rows = $folders;
            }
            $names = [];
            foreach ($rows as $folder_row) {
                $name = $folder_row['NAME'] ?? '';
                if ($name === '' || empty($folder_row['SELECTABLE'])) {
                    continue;
                }
                $names[] = $folder_row['NAME_HTML'] ?? $name;
            }
            $move_folder_options[$aid] = $names;
        }
        $move_folder_options_json = htmlspecialchars(
            json_encode($move_folder_options),
            ENT_QUOTES, 'UTF-8');
        ?>
        <div class="mail-inbox-pane">
        <h3 class="mail-inbox-heading">
            <span class="mail-inbox-heading-title"><?=
                $back_link ?> <?=
                $data['ACCOUNT_DISPLAY_NAME'] ?>
                <span class="mail-inbox-heading-count">(<?=
                $total ?>)</span> <?= $refresh_link
                ?> <?= $unread_toggle_link
                ?> <?= $compose_link ?></span>
            <?php $filter_input(); ?>
        </h3>
        <form method="post" action="<?= $bulk_url ?>"
            class="mail-inbox-form" id="mail-inbox-form">
        <input type="hidden" name="<?= C\p('CSRF_TOKEN') ?>"
            value="<?= $data[C\p('CSRF_TOKEN')] ?>">
        <div class="mail-inbox-bulk-bar"
            id="mail-inbox-bulk-bar"
            data-count-text="<?= tl(
                'mail_element_bulk_count_selected') ?>"
            data-confirm-one="<?= tl(
                'mail_element_bulk_confirm_one') ?>"
            data-confirm-many="<?= tl(
                'mail_element_bulk_confirm_many') ?>"
            data-move-value="move"
            data-current-account="<?= $current_account_id ?>"
            data-move-folders="<?= $move_folder_options_json ?>"
            data-instant-actions="mark_read,mark_unread,delete">
            <button type="button"
                id="mail-inbox-bulk-mode-toggle"
                class="mail-inbox-bulk-mode-toggle"
                aria-pressed="false"
                data-label-off="<?= tl(
                    'mail_element_bulk_mode_off') ?>"
                data-label-on="<?= tl(
                    'mail_element_bulk_mode_on') ?>"
                title="<?= tl(
                    'mail_element_bulk_mode_title') ?>"><?=
                tl('mail_element_bulk_mode_off') ?></button>
            <span class="mail-inbox-bulk-count
                mail-inbox-bulk-selection-required"
                id="mail-inbox-bulk-count"></span>
            <select id="mail-inbox-bulk-action"
                name="bulk_action"
                class="mail-inbox-bulk-action
                mail-inbox-bulk-selection-required">
                <option value=""><?= tl(
                    'mail_element_bulk_action_placeholder')
                    ?></option>
                <option value="mark_read"><?= tl(
                    'mail_element_bulk_mark_read') ?></option>
                <option value="mark_unread"><?= tl(
                    'mail_element_bulk_mark_unread') ?></option>
                <option value="delete"><?= tl(
                    'mail_element_bulk_delete') ?></option>
                <option value="move"><?= tl(
                    'mail_element_bulk_move_choose') ?></option>
            </select>
            <select id="mail-inbox-bulk-destination-account"
                name="destination_account_id"
                class="mail-inbox-bulk-destination-account none"><?php
                foreach ($accounts as $account_row) {
                    $aid = (int) ($account_row['ID'] ?? -1);
                    if ($aid < 0) {
                        continue;
                    }
                    $label = $account_row['DISPLAY_NAME'] ?? '';
                    $selected = ($aid === $current_account_id) ?
                        ' selected="selected"' : '';
                    ?>
                    <option value="<?= $aid ?>"<?= $selected
                        ?>><?= $label ?></option><?php
                } ?>
            </select>
            <select id="mail-inbox-bulk-destination"
                name="destination"
                data-active-folder="<?= $active ?>"
                data-placeholder="<?= tl(
                    'mail_element_bulk_move_placeholder') ?>"
                class="mail-inbox-bulk-destination none">
                <option value=""><?= tl(
                    'mail_element_bulk_move_placeholder')
                    ?></option><?php
                foreach ($folders as $folder_row) {
                    $name = $folder_row['NAME'] ?? '';
                    if ($name === '' || $name === $active ||
                        empty($folder_row['SELECTABLE'])) {
                        continue;
                    }
                    $name_html = $folder_row['NAME_HTML'] ?? $name;
                    ?>
                    <option value="<?= $name_html
                        ?>"><?= $name_html
                        ?></option><?php
                } ?>
            </select>
            <button type="button"
                id="mail-inbox-bulk-select-toggle"
                class="mail-inbox-bulk-select-toggle
                mail-inbox-bulk-mode-required"
                aria-pressed="false"
                data-label-all="<?= tl(
                    'mail_element_bulk_select_all_button')
                    ?>"
                data-label-none="<?= tl(
                    'mail_element_bulk_select_none_button')
                    ?>"><?=
                tl('mail_element_bulk_select_all_button')
                ?></button>
        </div>
        <div id="mail-inbox-scroll" class="mail-inbox-scroll"
            data-account-id="<?= (int) $data['ACCOUNT_ID'] ?>"
            data-loaded-count="<?= count($messages) ?>"
            data-oldest-seq="<?= (int) ($data['OLDEST_SEQ'] ?? 0) ?>"
            data-sort-mode="<?= $sort_mode ?>"
            data-sort-offset="<?= $sort_offset ?>"
            data-load-url="<?= $load_url ?>"
            onscroll="handleMailListScroll()">
        <table class="mail-inbox-table" id="mail-inbox-table">
        <colgroup>
            <col id="mail-col-subject">
            <col id="mail-col-star">
            <col id="mail-col-from">
            <col id="mail-col-date">
        </colgroup>
        <thead>
            <tr>
                <?php
                $sort_php_max = C\nsdefined('MAIL_PHP_SORT_MAX') ?
                    C\MAIL_PHP_SORT_MAX : 2000;
                $folder_size = (int) ($data['TOTAL_MESSAGES'] ?? 0);
                /* subject and from are sortable only when the
                   backend can order by them; a date-only backend
                   (MailSite today) leaves those two as plain text
                   while keeping the date header clickable. */
                $can_sort_header = !$sort_date_only &&
                    ($sort_supported ||
                    $folder_size <= $sort_php_max);
                ?>
                <th><?php if ($can_sort_header) { ?><a
                    class="mail-col-sort-link"
                    href="<?= $subject_url ?>"><?=
                    tl('mail_element_col_subject') ?></a><?php
                    } else { e(tl('mail_element_col_subject'));
                    } ?><span class="mail-col-resizer"
                    data-resize-col="mail-col-subject"></span></th>
                <th class="mail-col-star-cell"><a
                    class="mail-col-star-header<?=
                    $flagged_only ?
                    ' mail-col-star-header-active' :
                    '' ?>" href="<?= $flagged_toggle_url ?>"
                    title="<?= tl(
                    $flagged_only ?
                    'mail_element_show_all_title' :
                    'mail_element_show_flagged_title')
                    ?>" aria-label="<?= tl(
                    $flagged_only ?
                    'mail_element_show_all_title' :
                    'mail_element_show_flagged_title') ?>"><?=
                    $flagged_only ? '&#x2605;' : '&#x2606;'
                    ?></a><span class="mail-col-resizer"
                    data-resize-col="mail-col-star"></span></th>
                <th><?php if ($can_sort_header) { ?><a
                    class="mail-col-sort-link"
                    href="<?= $from_url ?>"><?=
                    tl('mail_element_col_from') ?></a><?php
                    } else { e(tl('mail_element_col_from'));
                    } ?><span class="mail-col-resizer"
                    data-resize-col="mail-col-from"></span></th>
                <th><a class="mail-col-sort-link"
                    href="<?= $date_url ?>"><?=
                    tl('mail_element_col_date') ?></a></th>
            </tr>
        </thead>
        <tbody id="mail-inbox-rows"><?php
        foreach ($messages as $msg) {
            e($this->renderInboxRow($data, $msg));
        } ?>
        </tbody>
        </table>
        </div>
        </form>
        </div>
        <?php
    }
    /**
     * Renders one inbox table row for a single message envelope.
     * Shared by the initial page render in renderInbox() and the
     * infinite-scroll fragment built in
     * SocialComponent::userMailLoadMessages(); both paths must emit
     * the same markup so appended rows match the originals. Returns
     * the row as a string rather than echoing so the controller can
     * concatenate a batch of them for its JSON response.
     *
     * @param array $data fields including ACCOUNT_ID and the CSRF
     *      token, used to build the message-view link
     * @param array $msg one envelope with keys UID, SUBJECT, FROM,
     *      DATE
     * @return string the rendered <tr> markup
     */
    public static function renderInboxRow($data, $msg)
    {
        $subject = ($msg['SUBJECT'] ?? '') !== '' ? $msg['SUBJECT'] :
            tl('mail_element_no_subject');
        $from = ($msg['FROM_NAME'] ?? '') !== '' ?
            $msg['FROM_NAME'] : ($msg['FROM'] ?? '');
        $date_ts = (int) ($msg['DATE_TS'] ?? 0);
        $date_raw = (string) ($msg['DATE'] ?? '');
        $date = self::renderInboxDate($date_ts, $date_raw);
        $uid = (int) ($msg['UID'] ?? 0);
        $answered_glyph = !empty($msg['IS_ANSWERED']) ?
            '<span class="mail-inbox-answered" title="' .
            tl('mail_element_answered') .
            '">&#x21A9;</span> ' : '';
        $is_flagged = !empty($msg['IS_FLAGGED']);
        if (!empty($msg['IS_PLACEHOLDER'])) {
            /* the envelope could not be fetched (a corrupted
               cache entry on the server, typically); render a
               non-clickable row so the inbox view stays intact
               without offering a link that would just hit the
               same server problem on viewMessage. */
            return '<tr class="mail-inbox-row-placeholder">' .
                '<td>' . $subject . '</td>' .
                '<td class="mail-col-star-cell"></td>' .
                '<td>' . $from .
                '</td><td>' . $date . '</td></tr>';
        }
        $url_args = ['arg' => 'viewMessage',
            'account_id' => $data['ACCOUNT_ID'],
            'uid' => $msg['UID']];
        if (!empty($data['ACTIVE_FOLDER'])) {
            $url_args['folder'] = $data['ACTIVE_FOLDER'];
        }
        $url = self::mailUrl($data, $url_args);
        $flag_url_args = ['arg' => 'toggleFlag',
            'account_id' => $data['ACCOUNT_ID'],
            'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
            'uid' => $msg['UID'],
            'flag' => $is_flagged ? '0' : '1'];
        $sort = $data['SORT'] ?? ['key' => 'date',
            'reverse' => false];
        if ($sort['key'] !== 'date') {
            $flag_url_args['sort_key'] = $sort['key'];
        }
        if (!empty($sort['reverse'])) {
            $flag_url_args['sort_reverse'] = '1';
        }
        $filter = $data['FILTER'] ?? '';
        if ($filter !== '') {
            $flag_url_args['filter'] = $filter;
        }
        if (!empty($data['UNREAD_ONLY'])) {
            $flag_url_args['unread_only'] = '1';
        }
        if (!empty($data['FLAGGED_ONLY'])) {
            $flag_url_args['flagged_only'] = '1';
        }
        $flag_url = self::mailUrl($data, $flag_url_args);
        $star_glyph = $is_flagged ? '&#x2605;' : '&#x2606;';
        $star_class = 'mail-inbox-star' . ($is_flagged ?
            ' mail-inbox-star-filled' : '');
        $star_title = $is_flagged ?
            tl('mail_element_unstar_title') :
            tl('mail_element_star_title');
        $tr_class = !empty($msg['IS_UNREAD']) ?
            ' class="mail-inbox-row-unread"' : '';
        return '<tr' . $tr_class . '>' .
            '<td>' . $answered_glyph .
            '<a class="mail-open-message" href="' . $url . '">' .
            $subject . '</a>' .
            '<input type="checkbox" class="mail-inbox-check"' .
            ' name="uids[]" value="' . $uid . '" hidden></td>' .
            '<td class="mail-col-star-cell">' .
            '<a href="' . $flag_url . '" class="' . $star_class .
            '" title="' . $star_title . '" aria-label="' .
            $star_title . '">' . $star_glyph . '</a></td>' .
            '<td>' . $from . '</td><td>' . $date .
            '<button type="button"' .
            ' class="mail-inbox-row-swipe-delete"' .
            ' aria-hidden="true" tabindex="-1">' .
            tl('mail_element_bulk_delete') . '</button>' .
            '</td></tr>';
    }
    /**
     * Formats a message's received timestamp the way Apple Mail
     * (and most modern mail clients) do: relative buckets that
     * compress as the message ages.
     *  - today:        time of day in the user-locale 24h format
     *                  e.g. "09:37"
     *  - yesterday:    "Yesterday"
     *  - within 6 days back: weekday name e.g. "Monday"
     *  - same year:    "May 14"
     *  - earlier:      "5/14/25"
     * The whole label is wrapped in a <time> element carrying
     * the ISO 8601 timestamp in its datetime attribute and the
     * full RFC 2822 string in its title attribute, so the user
     * can hover to see the precise original. When the timestamp
     * is unparseable, falls back to the raw value with no
     * <time> wrapper.
     *
     * Server time is used for the "today / yesterday / weekday"
     * bucketing. The Yioop user-base is typically on a single
     * timezone configured server-side, so this matches the
     * user's experience; a future patch can move the
     * relative-time computation to JS for true browser-locale
     * accuracy.
     *
     * @param int $ts Unix timestamp of the message (0 when not
     *      parseable)
     * @param string $raw the original Date header string, used
     *      as the title attribute and as the fallback when
     *      $ts is 0
     * @return string HTML for the date cell
     */
    public static function renderInboxDate($ts, $raw)
    {
        if ($ts <= 0) {
            return $raw;
        }
        $now = time();
        $today_midnight = strtotime('today');
        $yesterday_midnight = $today_midnight - 86400;
        $seven_days_back = $today_midnight - (6 * 86400);
        $this_year = (int) date('Y', $now);
        $message_year = (int) date('Y', $ts);
        if ($ts >= $today_midnight) {
            $label = date('H:i', $ts);
        } else if ($ts >= $yesterday_midnight) {
            $label = tl('mail_element_date_yesterday');
        } else if ($ts >= $seven_days_back) {
            $label = date('l', $ts);
        } else if ($message_year === $this_year) {
            $label = date('M j', $ts);
        } else {
            $label = date('n/j/y', $ts);
        }
        return '<time datetime="' . date('c',
            $ts) . '" title="' . $raw . '">' .
            $label . '</time>';
    }
    /**
     * Renders the Compose Message form. Fields come from
     * $data['FORM_VALUES'] (TO, SUBJECT, BODY) so a re-render
     * after a failed sendMessage POST preserves what the user
     * typed. Per-field validation messages come from
     * $data['FORM_ERRORS'] keyed by field name; a SEND key in
     * the same array carries an SMTP-level failure description
     * shown as a top-of-form red banner.
     *
     * @param array $data fields with CONTENT_PANE = 'compose',
     *      ACCOUNT_ID, ACCOUNT_DISPLAY_NAME, FROM_DISPLAY,
     *      FORM_VALUES, FORM_ERRORS
     */
    protected function renderCompose($data)
    {
        $values = $data['FORM_VALUES'] ?? [];
        $errors = $data['FORM_ERRORS'] ?? [];
        $inbox_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        $cancel_url = $this->mailUrl($data, $inbox_args);
        $form_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=sendMessage&account_id=" .
            (int) $data['ACCOUNT_ID'];
        ?>
        <div class="mail-compose-pane">
            <h3 class="mail-compose-heading"><?=
                $data['ACCOUNT_DISPLAY_NAME'] ?> &mdash; <?=
                tl('mail_element_compose_heading') ?>
                <label class="mail-compose-attach-button"
                    for="mail-compose-attachments"
                    title="<?= tl('mail_element_compose_attach')
                    ?>"
                    aria-label="<?= tl(
                    'mail_element_compose_attach') ?>"
                    >&#128206;</label></h3>
            <?php
            if (!empty($errors['SEND'])) { ?>
                <p class="red"><?= $errors['SEND'] ?></p><?php
            } ?>
            <form method="post" action="<?= $form_url ?>"
                class="mail-compose-form"
                enctype="multipart/form-data">
                <?php
                $reply_context = $data['REPLY_CONTEXT'] ?? [];
                if (!empty($reply_context['KIND'])) {
                    ?>
                    <input type="hidden" name="reply_kind"
                        value="<?=
                        $reply_context['KIND'] ?>">
                    <input type="hidden" name="reply_uid"
                        value="<?= (int) ($reply_context['UID'] ?? 0)
                        ?>">
                    <input type="hidden" name="reply_folder"
                        value="<?=
                        $reply_context['FOLDER'] ?? '' ?>">
                    <input type="hidden" name="reply_message_id"
                        value="<?=
                        $reply_context['MESSAGE_ID'] ?? '' ?>">
                    <input type="hidden" name="reply_references"
                        value="<?=
                        $reply_context['REFERENCES'] ?? '' ?>">
                    <input type="hidden" name="reply_account_id"
                        value="<?= (int) (
                        $reply_context['SOURCE_ACCOUNT_ID'] ?? 0) ?>">
                    <?php
                }
                $from_options = $data['FROM_OPTIONS'] ?? [];
                $from_value = $data['FROM_VALUE'] ?? '';
                ?>
                <div class="mail-compose-row mail-compose-from-row">
                    <label class="mail-compose-label"
                        for="mail-compose-from"><?=
                        tl('mail_element_field_from') ?></label>
                    <?php
                    if (count($from_options) > 1) { ?>
                    <select name="from_option"
                        id="mail-compose-from"
                        class="mail-compose-input"><?php
                        foreach ($from_options as $opt) {
                            ?>
                        <option value="<?= $opt['VALUE'] ?>"<?=
                            ($opt['VALUE'] === $from_value) ?
                            ' selected' : '' ?>><?=
                            $opt['ADDRESS'] ?></option><?php
                        } ?>
                    </select><?php
                    } else {
                        $only = $from_options[0]['ADDRESS'] ??
                            ($data['FROM_DISPLAY'] ?? '');
                        $only_value = $from_options[0]['VALUE'] ??
                            $from_value;
                        ?>
                    <input type="hidden" name="from_option"
                        value="<?= $only_value ?>">
                    <span class="mail-compose-from"><?= $only
                        ?></span><?php
                    } ?>
                </div>
                <?php
                $cc_visible = ($values['CC'] ?? '') !== '' ||
                    !empty($errors['CC']);
                $bcc_visible = ($values['BCC'] ?? '') !== '' ||
                    !empty($errors['BCC']);
                ?>
                <div class="mail-compose-row mail-compose-to-row">
                    <div class="mail-compose-field">
                        <label class="mail-compose-field-label"
                            for="mail-compose-to"><?=
                            tl('mail_element_field_to')
                            ?></label><input type="text" name="to"
                            id="mail-compose-to"
                            class="mail-compose-input"
                            autofocus
                            aria-label="<?= tl(
                            'mail_element_field_to') ?>"
                            value="<?= $values['TO'] ?? '' ?>">
                        <span class="mail-compose-recipient-toggles">
                            <button type="button"
                                id="mail-compose-cc-toggle"
                                class="mail-compose-recipient-toggle<?=
                                $cc_visible ? ' none' : '' ?>"
                                data-target="mail-compose-cc-row"
                                aria-controls="mail-compose-cc-row"
                                aria-expanded="<?= $cc_visible ?
                                'true' : 'false' ?>"><?=
                                tl('mail_element_field_cc')
                                ?></button>
                            <button type="button"
                                id="mail-compose-bcc-toggle"
                                class="mail-compose-recipient-toggle<?=
                                $bcc_visible ? ' none' : '' ?>"
                                data-target="mail-compose-bcc-row"
                                aria-controls="mail-compose-bcc-row"
                                aria-expanded="<?= $bcc_visible ?
                                'true' : 'false' ?>"><?=
                                tl('mail_element_field_bcc')
                                ?></button>
                        </span>
                    </div>
                    <?php
                    if (!empty($errors['TO'])) { ?>
                        <span class="red"><?= $errors['TO'] ?></span><?php
                    } ?>
                </div>
                <div class="mail-compose-row<?= $cc_visible ? '' :
                    ' none' ?>" id="mail-compose-cc-row">
                    <div class="mail-compose-field">
                        <button type="button"
                            class="mail-compose-field-label
                            mail-compose-field-collapse"
                            data-collapse-target="mail-compose-cc-row"
                            data-restore-toggle="mail-compose-cc-toggle"
                            aria-label="<?= tl(
                            'mail_element_field_cc_collapse') ?>"><?=
                            tl('mail_element_field_cc')
                            ?></button><input type="text" name="cc"
                            id="mail-compose-cc"
                            class="mail-compose-input"
                            aria-label="<?= tl(
                            'mail_element_field_cc') ?>"
                            value="<?= $values['CC'] ?? '' ?>">
                    </div>
                    <?php
                    if (!empty($errors['CC'])) { ?>
                        <span class="red"><?= $errors['CC'] ?></span><?php
                    } ?>
                </div>
                <div class="mail-compose-row<?= $bcc_visible ? '' :
                    ' none' ?>" id="mail-compose-bcc-row">
                    <div class="mail-compose-field">
                        <button type="button"
                            class="mail-compose-field-label
                            mail-compose-field-collapse"
                            data-collapse-target="mail-compose-bcc-row"
                            data-restore-toggle="mail-compose-bcc-toggle"
                            aria-label="<?= tl(
                            'mail_element_field_bcc_collapse') ?>"><?=
                            tl('mail_element_field_bcc')
                            ?></button><input type="text" name="bcc"
                            id="mail-compose-bcc"
                            class="mail-compose-input"
                            aria-label="<?= tl(
                            'mail_element_field_bcc') ?>"
                            value="<?= $values['BCC'] ?? '' ?>">
                    </div>
                    <?php
                    if (!empty($errors['BCC'])) { ?>
                        <span class="red"><?= $errors['BCC'] ?></span><?php
                    } ?>
                </div>
                <div class="mail-compose-row">
                    <div class="mail-compose-field">
                        <label class="mail-compose-field-label"
                            for="mail-compose-subject"><?=
                            tl('mail_element_field_subject')
                            ?></label><input type="text" name="subject"
                            id="mail-compose-subject"
                            class="mail-compose-input"
                            aria-label="<?= tl(
                            'mail_element_field_subject') ?>"
                            value="<?= $values['SUBJECT'] ?? '' ?>">
                    </div>
                </div>
                <div class="mail-compose-row mail-compose-body-row">
                    <textarea name="body"
                        id="mail-compose-body"
                        class="mail-compose-body"
                        aria-label="<?= tl(
                        'mail_element_field_body') ?>"
                        ><?= $values['BODY'] ?? '' ?></textarea>
                    <?php
                    if (!empty($errors['BODY'])) { ?>
                        <span class="red"><?= $errors['BODY'] ?></span><?php
                    } ?>
                </div>
                <input type="file" multiple
                    name="attachments[]"
                    id="mail-compose-attachments"
                    class="mail-compose-file-input">
                <div class="mail-compose-attachments"
                    id="mail-compose-file-list"></div>
                <?php
                if (!empty($errors['ATTACH'])) { ?>
                    <p class="red"><?= $errors['ATTACH'] ?></p><?php
                } ?>
                <div class="mail-compose-buttons">
                    <span class="mail-compose-send-split"
                        id="mail-compose-send-split"
                        data-schedule-button-label="<?=
                        tl(
                        'mail_element_compose_schedule_button')
                        ?>"
                        data-send-button-label="<?= tl(
                            'mail_element_compose_send') ?>">
                        <button type="submit"
                            id="mail-compose-send-button"
                            class="mail-compose-button
                            mail-compose-send-button"><?=
                            tl('mail_element_compose_send')
                            ?></button>
                        <button type="button"
                            id="mail-compose-schedule-toggle"
                            class="mail-compose-button
                            mail-compose-schedule-toggle"
                            aria-expanded="false"
                            aria-controls="mail-compose-schedule-popover"
                            aria-label="<?= tl(
                            'mail_element_compose_schedule') ?>"
                            title="<?= tl(
                            'mail_element_compose_schedule') ?>"
                            >&#x2304;</button>
                        <div id="mail-compose-schedule-popover"
                            class="mail-compose-schedule-popover
                            none" role="menu"
                            aria-labelledby="mail-compose-schedule-toggle">
                            <div
                            class="mail-compose-schedule-heading"><?=
                            tl(
                            'mail_element_compose_schedule_heading')
                            ?></div>
                            <button type="button"
                                class="mail-compose-schedule-preset"
                                data-preset="tomorrow_morning"
                                role="menuitem"><?=
                                tl(
            'mail_element_compose_schedule_tomorrow_morning')
                                ?></button>
                            <button type="button"
                                class="mail-compose-schedule-preset"
                                data-preset="tomorrow_afternoon"
                                role="menuitem"><?=
                                tl(
            'mail_element_compose_schedule_tomorrow_afternoon')
                                ?></button>
                            <button type="button"
                                id="mail-compose-schedule-choose"
                                class="mail-compose-schedule-preset
                                mail-compose-schedule-choose"
                                role="menuitem"><?= tl(
                                'mail_element_compose_schedule_choose')
                                ?></button>
                            <div
                                id="mail-compose-schedule-custom"
                                class="mail-compose-schedule-custom
                                none">
                                <input type="datetime-local"
                                    id="mail-compose-schedule-input"
                                    class="mail-compose-schedule-input">
                            </div>
                        </div>
                    </span>
                    <a class="mail-compose-button"
                        href="<?= $cancel_url ?>"><?=
                        tl('mail_element_compose_cancel')
                        ?></a>
                    <span id="mail-compose-schedule-status"
                        class="mail-compose-schedule-status none"
                        data-pattern="<?= tl(
                        'mail_element_compose_schedule_pattern')
                        ?>"></span>
                    <button type="button"
                        id="mail-compose-schedule-clear"
                        class="mail-compose-schedule-clear none"
                        aria-label="<?= tl(
                        'mail_element_compose_schedule_cancel_choice')
                        ?>" title="<?= tl(
                        'mail_element_compose_schedule_cancel_choice')
                        ?>">&#x2715;</button>
                </div>
                <input type="hidden" name="scheduled_at"
                    id="mail-compose-scheduled-at" value="0">
                <input type="hidden" name="<?= C\p('CSRF_TOKEN')
                    ?>" value="<?= $data[C\p('CSRF_TOKEN')] ?>">
            </form>
        </div>
        <?php
    }
    /**
     * Renders the per-account Scheduled folder view: a table of
     * pending and failed scheduled-send rows, each with target
     * time, recipients, subject, status, and a Cancel button.
     * Empty state shows a friendly "no scheduled messages" with
     * a compose link.
     *
     * Time formatting uses PHP's date() with a Y-m-d H:i format
     * so the rendered times are unambiguous (no MM/DD vs DD/MM
     * ambiguity); JS could prettify to "in 2 hours" later but
     * the absolute time stays the canonical display so the user
     * can verify exactly what they scheduled.
     *
     * @param array $data fields including SCHEDULED_MESSAGES,
     *      ACCOUNT_ID, ACCOUNT_DISPLAY_NAME, CSRF_TOKEN
     */
    protected function renderScheduledList($data)
    {
        $account_id = (int) ($data['ACCOUNT_ID'] ?? 0);
        $messages = $data['SCHEDULED_MESSAGES'] ?? [];
        $cancel_url = B\controllerUrl("user_mail", true) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
            "&arg=scheduledCancel&account_id=" . $account_id;
        ?>
        <div class="mail-scheduled-pane">
            <h3 class="mail-scheduled-heading"><?=
                $data['ACCOUNT_DISPLAY_NAME'] ?? '' ?>
                &mdash; <?=
                tl('mail_element_folder_scheduled') ?></h3>
            <?php
            if (empty($messages)) { ?>
                <p class="mail-scheduled-empty"><?=
                    tl('mail_element_scheduled_empty') ?></p><?php
            } else { ?>
                <table class="mail-scheduled-table">
                <thead><tr>
                    <th><?= tl('mail_element_scheduled_when')
                        ?></th>
                    <th><?= tl('mail_element_scheduled_to')
                        ?></th>
                    <th><?= tl('mail_element_scheduled_subject')
                        ?></th>
                    <th><?= tl('mail_element_scheduled_status')
                        ?></th>
                    <th></th>
                </tr></thead>
                <tbody><?php
                foreach ($messages as $message) {
                    $row_id = (int) $message['ID'];
                    $when = date('Y-m-d H:i',
                        (int) $message['SCHEDULED_AT']);
                    $recipients = $message['TO_LIST_DISPLAY'] ?? '';
                    $subject = $message['SUBJECT_DISPLAY'] ?? '';
                    $status = $message['STATUS'] ?? 'pending';
                    $status_class = "mail-scheduled-status-" .
                        $status;
                    if ($status === 'failed') {
                        $status_label =
                            tl('mail_element_scheduled_status_failed');
                    } else if ($status === 'sending') {
                        $status_label =
                            tl('mail_element_scheduled_status_sending');
                    } else {
                        $status_label =
                            tl('mail_element_scheduled_status_pending');
                    }
                    ?>
                    <tr>
                        <td><?= $when ?></td>
                        <td><?= $recipients
                            ?></td>
                        <td><?= $subject ?></td>
                        <td><span class="<?= $status_class
                            ?>" title="<?=
                            $message['LAST_ERROR_DISPLAY'] ?? ''
                            ?>"><?= $status_label
                            ?></span></td>
                        <td>
                            <form method="post" action="<?=
                                $cancel_url ?>"
                                class="mail-scheduled-cancel-form"
                                onsubmit="return confirm(<?=
                                '\'' . addslashes(
                                tl(
                                'mail_element_scheduled_cancel_confirm'
                                )) . '\'' ?>);">
                                <input type="hidden" name="<?=
                                    C\p('CSRF_TOKEN') ?>" value="<?=
                                    $data[C\p('CSRF_TOKEN')] ?>">
                                <input type="hidden"
                                    name="scheduled_id" value="<?=
                                    $row_id ?>">
                                <button type="submit"
                                    class="mail-scheduled-cancel-button"
                                    ><?= tl(
                                    'mail_element_scheduled_cancel')
                                    ?></button>
                            </form>
                        </td>
                    </tr><?php
                } ?>
                </tbody>
                </table><?php
            } ?>
        </div>
        <?php
    }
    /**
     * Renders a single-message view in the content pane: a Back to
     * inbox link, a labeled headers block (Subject, From, To, Cc,
     * Date), the body (sanitised HTML if present, plain text
     * otherwise), and a list of attachment download links. The
     * outer .mail-message-pane wraps the content as a flex column
     * so the body can scroll internally while header and
     * attachments stay visible -- previously the body's
     * max-height: 60vh was viewport-relative and could push the
     * attachment list below a resizable pane's visible area.
     * IMAP errors are surfaced inline.
     *
     * @param array $data fields including ACCOUNT_ID, UID,
     *      MIME_MESSAGE (parsed body), MESSAGE_BODY (raw fallback
     *      when MIME parsing failed), ACTIVE_FOLDER, IMAP_ERROR
     *      (when set)
     */
    protected function renderMessage($data)
    {
        $inbox_args = ['arg' => 'listMessages',
            'account_id' => $data['ACCOUNT_ID']];
        if (!empty($data['ACTIVE_FOLDER'])) {
            $inbox_args['folder'] = $data['ACTIVE_FOLDER'];
        }
        $inbox_url = $this->mailUrl($data, $inbox_args);
        $reply_base_args = ['arg' => 'compose',
            'account_id' => $data['ACCOUNT_ID'],
            'reply_uid' => $data['UID'] ?? 0,
            'reply_folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX'];
        $reply_url = $this->mailUrl($data,
            $reply_base_args + ['reply_kind' => 'reply']);
        $reply_all_url = $this->mailUrl($data,
            $reply_base_args + ['reply_kind' => 'reply_all']);
        $forward_url = $this->mailUrl($data,
            $reply_base_args + ['reply_kind' => 'forward']);
        $can_reply = !empty($data['MIME_MESSAGE']) &&
            empty($data['IMAP_ERROR']);
        $can_delete = !empty($data['UID']) &&
            !empty($data['ACTIVE_FOLDER']) &&
            empty($data['IMAP_ERROR']);
        $bulk_url = $this->mailUrl($data, [
            'arg' => 'bulkAction',
            'account_id' => $data['ACCOUNT_ID'],
            'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
        $icon_helper = $this->view->helper('iconlink');
        ?>
        <div class="mail-message-pane">
        <div class="mail-message-back">
            <a class="gray-link" href="<?= $inbox_url ?>"><?=
                tl('mail_element_back_to_folder',
                $data['ACTIVE_FOLDER_HTML'] ??
                ($data['ACTIVE_FOLDER'] ?? 'INBOX'))
                ?></a>
            <?php
            if ($can_reply || $can_delete) { ?>
                <div class="mail-message-actions"><?php
                    if ($can_reply) { ?>
                    <span class="mail-message-actions-reply-group">
                        <?= $icon_helper->renderButton($reply_url,
                            'mail_reply', '', true) ?>
                        <?= $icon_helper->renderButton(
                            $reply_all_url, 'mail_reply_all', '',
                            true) ?>
                        <?= $icon_helper->renderButton($forward_url,
                            'mail_forward', '', true) ?>
                    </span><?php
                    } ?>
                    <?php
                    $folders = $data['FOLDERS'] ?? [];
                    $move_targets = [];
                    foreach ($folders as $folder_row) {
                        $name = $folder_row['NAME'] ?? '';
                        if ($name === '' ||
                                $name === ($data['ACTIVE_FOLDER']
                                ?? '') ||
                                empty($folder_row['SELECTABLE'])) {
                            continue;
                        }
                        $move_targets[] = $folder_row['NAME_HTML']
                            ?? $name;
                    }
                    if ($can_delete && !empty($move_targets)) {
                        ?>
                    <form method="post" action="<?= $bulk_url ?>"
                        class="mail-message-move-form">
                        <input type="hidden" name="<?= C\p('CSRF_TOKEN')
                            ?>" value="<?= $data[C\p('CSRF_TOKEN')]
                            ?>">
                        <input type="hidden" name="bulk_action"
                            value="move">
                        <input type="hidden" name="uids[]"
                            value="<?= (int) $data['UID'] ?>">
                        <select name="destination"
                            class="mail-message-move-destination"
                            onchange="if (this.value) {
                                this.form.submit(); }">
                            <option value="" selected disabled
                                hidden><?= tl(
                                'mail_element_message_move')
                                ?></option><?php
                            foreach ($move_targets as $name_html) {
                                ?>
                            <option value="<?=
                                $name_html ?>"><?=
                                $name_html ?></option><?php
                            } ?>
                        </select>
                    </form><?php
                    } ?><?php
                    if ($can_delete) { ?>
                    <span class="mail-message-actions-trash-group">
                    <form method="post" action="<?= $bulk_url ?>"
                        class="mail-message-delete-form"
                        onsubmit="return confirm(<?=
                        '\'' . addslashes(tl(
                        'mail_element_bulk_confirm_one')) . '\''
                        ?>);">
                        <input type="hidden" name="<?= C\p('CSRF_TOKEN')
                            ?>" value="<?= $data[C\p('CSRF_TOKEN')]
                            ?>">
                        <input type="hidden" name="bulk_action"
                            value="delete">
                        <input type="hidden" name="uids[]"
                            value="<?= (int) $data['UID'] ?>">
                        <div class="icon-button-container">
                        <?= $icon_helper->renderFormButton('submit',
                            'delete', true,
                            'mail-message-delete-button') ?>
                        </div>
                    </form>
                    </span><?php
                    } ?>
                </div><?php
            } ?>
        </div>
        <?php
        if (!empty($data['IMAP_ERROR'])) {
            ?><p class="red"><?=
                $data['IMAP_ERROR'] ?></p>
            </div><?php
            return;
        }
        $mime = $data['MIME_MESSAGE'] ?? null;
        $message_view = $data['MESSAGE_VIEW'] ?? [];
        if ($mime === null) {
            ?>
            <pre class="mail-message-body wordwrap"><?=
                $message_view['BODY_RAW'] ??
                ($data['MESSAGE_BODY'] ?? '')
                ?></pre>
            </div>
            <?php
            return;
        }
        $headers = $message_view['HEADERS'] ?? [];
        $row = function ($label, $value) {
            if ($value === '' || $value === null) {
                return;
            }
            ?>
            <div class="mail-message-header-row"
                data-mail-pretty-only>
                <span class="mail-message-header-label"><?=
                    $label ?></span>
                <span class="mail-message-header-value"><?=
                    $value ?></span>
            </div>
            <?php
        };
        $sanitized_html = ($mime->body_html !== '') ?
            HtmlSanitizer::sanitize($mime->body_html, true) : '';
        $has_blocked_images = (strpos($sanitized_html,
            'mail-blocked-image') !== false);
        ?>
        <div class="mail-message-headers">
            <button type="button" class="mail-message-toggle-raw"
                aria-pressed="false"
                title="<?= tl('mail_element_toggle_raw_title')
                ?>"><?= tl('mail_element_toggle_raw') ?></button>
            <?php
            if (!empty($data['DKIM_STATUS'])) {
                $dkim_status = $data['DKIM_STATUS'];
                $dkim_class = 'mail-dkim-' . $dkim_status;
                if ($dkim_status === 'pass') {
                    $dkim_title = tl('mail_element_dkim_pass');
                    $dkim_summary =
                        tl('mail_element_dkim_summary_pass');
                } else if ($dkim_status === 'fail') {
                    $dkim_title = tl('mail_element_dkim_fail');
                    $dkim_summary =
                        tl('mail_element_dkim_summary_fail');
                } else {
                    $dkim_title = tl('mail_element_dkim_no_key');
                    $dkim_summary =
                        tl('mail_element_dkim_summary_no_key');
                }
                $bh_header = $data['DKIM_BODY_HASH_HEADER'] ?? '';
                $bh_computed = $data['DKIM_BODY_HASH_COMPUTED'] ?? '';
                $bh_match = !empty($data['DKIM_BODY_HASH_MATCH']);
                $signature_ok = !empty($data['DKIM_SIGNATURE_OK']);
                $key_found = !empty($data['DKIM_KEY_FOUND']);
                $yes = tl('mail_element_dkim_yes');
                $no = tl('mail_element_dkim_no');
                ?>
                <span class="mail-dkim-badge-wrap">
                    <button type="button"
                        class="mail-dkim-icon <?= $dkim_class ?>"
                        aria-expanded="false"
                        title="<?= $dkim_title ?>"><svg
                        viewBox="0 0 24 24" width="18" height="18"
                        aria-hidden="true"><path fill="currentColor"
                        d="M12 1 3 5v6c0 5 3.8 9.4 9 11 5.2-1.6 9-6
                        9-11V5l-9-4z"/></svg></button>
                    <div class="mail-dkim-detail none">
                        <div class="mail-dkim-detail-title"><?=
                            $dkim_title ?></div>
                        <table class="mail-dkim-detail-table"><?php
                            $detail_row = function ($label,
                                $value) {
                                if ($value === '' ||
                                    $value === null) {
                                    return;
                                }
                                ?><tr><th><?= $label
                                    ?></th><td><?= $value
                                    ?></td></tr><?php
                            };
                            $detail_row(
                                tl('mail_element_dkim_signing_domain'),
                                $data['DKIM_DOMAIN'] ?? '');
                            $detail_row(
                                tl('mail_element_dkim_algorithm'),
                                $data['DKIM_ALGORITHM'] ?? '');
                            $detail_row(
                                tl('mail_element_dkim_lookup'),
                                $data['DKIM_DNS_NAME'] ?? '');
                            $detail_row(
                                tl('mail_element_dkim_key_status'),
                                $key_found ?
                                tl('mail_element_dkim_key_found') :
                                tl('mail_element_dkim_key_missing'));
                            $detail_row(
                                tl('mail_element_dkim_headers_signed'),
                                $data['DKIM_SIGNED_HEADERS'] ?? '');
                            $detail_row(
                                tl('mail_element_dkim_body_hash_header'),
                                $bh_header);
                            $detail_row(
                                tl(
                                'mail_element_dkim_body_hash_computed'),
                                $bh_computed);
                            if ($key_found) {
                                $detail_row(
                                    tl(
                                    'mail_element_dkim_body_hash_match'),
                                    $bh_match ? $yes : $no);
                                $detail_row(
                                    tl(
                                    'mail_element_dkim_signature_check'),
                                    $signature_ok ? $yes : $no);
                            }
                            ?>
                        </table>
                        <div class="mail-dkim-detail-summary"><?=
                            $dkim_summary ?></div>
                    </div>
                </span><?php
            }
            $row(tl('mail_element_field_subject'),
                $headers['subject'] ?? '');
            $row(tl('mail_element_field_from'),
                $headers['from'] ?? '');
            $row(tl('mail_element_field_to'),
                $headers['to'] ?? '');
            $row(tl('mail_element_field_cc'),
                $headers['cc'] ?? '');
            $row(tl('mail_element_field_date'),
                $headers['date'] ?? '');
            ?>
        </div>
        <?php
        if (!empty($data['SHOW_TRUST_SENDER'])) {
            $trust_url = $this->mailUrl($data, [
                'arg' => 'trustSender',
                'account_id' => $data['ACCOUNT_ID'],
                'uid' => $data['UID'],
                'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
            ?>
            <div class="mail-message-notice">
                <?= tl('mail_element_trust_sender_blurb') ?>
                <a href="<?= $trust_url ?>"><?=
                    tl('mail_element_trust_sender_link') ?></a>
            </div>
            <?php
        }
        if (!empty($data['SHOW_UNTRUST_SENDER'])) {
            $untrust_url = $this->mailUrl($data, [
                'arg' => 'untrustSender',
                'account_id' => $data['ACCOUNT_ID'],
                'uid' => $data['UID'],
                'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
            ?>
            <div class="mail-message-notice">
                <?= tl('mail_element_untrust_sender_blurb') ?>
                <a href="<?= $untrust_url ?>"><?=
                    tl('mail_element_untrust_sender_link') ?></a>
            </div>
            <?php
        }
        if ($has_blocked_images) { ?>
            <div class="mail-message-load-images"
                data-mail-pretty-only>
                <button type="button"
                    class="mail-message-load-images-button"><?=
                    tl('mail_element_load_remote_images')
                    ?></button>
            </div><?php
        } ?>
        <div class="mail-message-body mail-message-body-rendered"
            data-mail-view="rendered">
        <?php
        $use_body_parts = count($mime->body_parts) > 1;
        foreach ($mime->body_parts as $body_part) {
            if ($body_part['kind'] === 'image') {
                $use_body_parts = true;
            }
        }
        if ($use_body_parts) {
            $this->renderMailBodyParts($mime->body_parts);
        } else if ($sanitized_html !== '') {
            ?>
            <div class="mail-message-body-html"><?=
                $sanitized_html ?></div>
            <?php
        } else {
            ?>
            <pre class="mail-message-body-text"><?=
                $message_view['BODY_TEXT'] ?? '' ?></pre>
            <?php
        }
        ?>
        </div>
        <pre class="mail-message-body mail-message-body-raw none"
            data-mail-view="raw"><?=
            $message_view['BODY_RAW'] ??
            ($data['MESSAGE_BODY'] ?? '')
            ?></pre>
        <?php
        if (!empty($mime->attachments)) {
            $attach_count = count($mime->attachments);
            $download_all_url = $this->mailUrl($data, [
                'arg' => 'downloadAllAttachments',
                'account_id' => $data['ACCOUNT_ID'],
                'uid' => $data['UID'],
                'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
            ]);
            ?>
            <div class="mail-attachment-list" data-mail-pretty-only>
                <div class="mail-attachment-list-header">
                    <h4 class="mail-attachment-heading"><?=
                        tl('mail_element_attachments_header') ?>
                        (<?= $attach_count ?>)</h4><?php
                    if ($attach_count > 1) { ?>
                        <a class="mail-attachment-download-all"
                            href="<?= $download_all_url ?>"><?=
                            tl('mail_element_attachments_download_all')
                            ?></a><?php
                    } ?>
                </div>
                <div class="mail-attachment-grid"><?php
                foreach ($mime->attachments as $index =>
                        $attach) {
                    $download_url = $this->mailUrl($data, [
                        'arg' => 'downloadAttachment',
                        'account_id' => $data['ACCOUNT_ID'],
                        'uid' => $data['UID'],
                        'folder' => $data['ACTIVE_FOLDER'] ??
                            'INBOX',
                        'index' => $index,
                    ]);
                    $thumb_uri = $this->attachmentThumbDataUri(
                        $attach);
                    $attach_view =
                        $message_view['ATTACHMENTS'][$index] ?? [];
                    $filename_html =
                        $attach_view['FILENAME_HTML'] ?? '';
                    $ext_label = $attach_view['EXT_LABEL'] ?? '';
                    ?>
                    <a class="mail-attachment-card"
                        href="<?= $download_url ?>"
                        download="<?=
                        $filename_html ?>"
                        title="<?=
                        $filename_html ?>">
                        <div class="mail-attachment-thumb"><?php
                            if ($thumb_uri !== '') { ?>
                                <img src="<?= $thumb_uri ?>"
                                    alt=""><?php
                            } else { ?>
                                <span
                                    class="mail-attachment-ext-label"
                                    ><?=
                                    $ext_label ?></span><?php
                            } ?>
                        </div>
                        <div class="mail-attachment-info">
                            <div class="mail-attachment-filename"
                                ><?=
                                $filename_html ?></div>
                            <div class="mail-attachment-meta"
                                ><?= $this->formatAttachmentSize(
                                $attach['size']) ?></div>
                        </div>
                    </a><?php
                } ?>
                </div>
            </div>
            <?php
        }
        ?>
        </div>
        <?php
    }
    /**
     * Formats an attachment byte size as a short display string
     * with a unit (B, KB, MB). The threshold for switching units
     * is 1024 bytes per step, so a 1023-byte file displays as
     * "1023 B" while a 1025-byte file displays as "1 KB". Sizes
     * above one megabyte are shown to one decimal place, which is
     * enough precision for picking-an-attachment context without
     * looking pedantic.
     *
     * @param int $bytes the size in bytes
     * @return string a short human-friendly display string
     */
    protected function formatAttachmentSize($bytes)
    {
        if ($bytes < 1024) {
            return $bytes . " " . tl('mail_element_size_bytes');
        }
        if ($bytes < 1024 * 1024) {
            return intval($bytes / 1024) . " " .
                tl('mail_element_size_kb');
        }
        return number_format($bytes / (1024 * 1024), 1) . " " .
            tl('mail_element_size_mb');
    }
    /**
     * Draws the body of a message piece by piece in the order the
     * sender arranged it, so an image shows exactly where it sits
     * between blocks of text. A text piece is shown as escaped plain
     * text, an html piece is cleaned before display the same way the
     * single-html path is, and an image piece is drawn from its own
     * bytes as a data URL so it needs no extra request.
     *
     * @param array $body_parts the ordered pieces from
     *      MimeMessage::parse, each with a "kind" of text, html, or
     *      image
     * @return void the pieces are written straight to the page
     */
    protected function renderMailBodyParts($body_parts)
    {
        foreach ($body_parts as $body_part) {
            if ($body_part['kind'] === 'image') {
                $data_url = 'data:' . $body_part['mime_type'] .
                    ';base64,' . base64_encode($body_part['content']);
                ?>
                <img class="mail-message-inline-image" alt=""
                    src="<?= $data_url ?>"><?php
            } else if ($body_part['kind'] === 'html') {
                ?>
                <div class="mail-message-body-html"><?=
                    HtmlSanitizer::sanitize($body_part['html'], true)
                    ?></div><?php
            } else {
                ?>
                <pre class="mail-message-body-text"><?=
                    htmlspecialchars($body_part['text'], ENT_QUOTES,
                    'UTF-8') ?></pre><?php
            }
        }
    }
    /**
     * For an image attachment, returns a webp thumbnail as a
     * data URI ready to drop into an <img src="..."> attribute;
     * for non-image attachments or when thumbnail generation
     * fails (corrupt bytes, unsupported codec, GD unable to
     * decode), returns the empty string so the caller can fall
     * back to a generic icon.
     *
     * The attachment bytes are already in memory from the MIME
     * parse, so generating thumbs inline is just a few extra GD
     * calls per image attachment. Embedding the thumb as a data
     * URI avoids the extra HTTP round-trips and re-parsing cost
     * of a separate thumb endpoint.
     *
     * @param array $attach attachment record with mime_type
     *      and content keys (the format MimeMessage::parse
     *      produces)
     * @return string a complete data:image/webp;base64,... URI
     *      or '' if no thumb could be made
     */
    protected function attachmentThumbDataUri($attach)
    {
        $mime_type = $attach['mime_type'] ?? '';
        if (!str_starts_with(strtolower($mime_type), 'image/')) {
            return '';
        }
        $bytes = $attach['content'] ?? '';
        if ($bytes === '') {
            return '';
        }
        $captured = '';
        set_error_handler(
            function ($errno, $errstr) use (&$captured) {
                if (strlen($captured) < 256) {
                    $captured .= $errstr;
                }
            });
        try {
            $image = imagecreatefromstring($bytes);
            if ($image === false) {
                return '';
            }
            $thumb_bytes = ImageProcessor::createThumb($image,
                64, 64);
        } finally {
            restore_error_handler();
        }
        if ($thumb_bytes === '' || $thumb_bytes === false) {
            return '';
        }
        return 'data:image/webp;base64,' .
            base64_encode($thumb_bytes);
    }
}
X