<?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\controllers\components;

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\av_processing\VideoExtractor;
use seekquarry\yioop\models\MailAccountModel;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\MailScheduledDispatcher;
use seekquarry\yioop\library\mail\MailHeaderParser;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\language_processing\PhraseParser;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\mail\ImapEnvelopeParser;
use seekquarry\yioop\library\mail\ImapListing;
use seekquarry\yioop\library\mail\ImapFolderListParser;
use seekquarry\yioop\library\mail\ImapResponseParser;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\MailComposeBuilder;
use seekquarry\yioop\views\elements\MailElement;
use seekquarry\yioop\library\media_jobs as LMJ;
use seekquarry\yioop\library\version_control as LVC;
use seekquarry\yioop\library\wiki as LW;
/**
 * MailComponent draws the mail screens of Yioop and saves what a user
 * does on them. A user can read mail from an outside account, such as
 * one at a mail provider, without leaving Yioop.
 *
 * It draws the screen that lists a user's mail accounts. It adds an
 * account, edits one, turns one off, deletes one, and changes the order
 * they appear in. It draws the folders of a mailbox and the messages in
 * a folder, and the screen for writing a message. It sends a message
 * and schedules one for later. It starts a copy of an outside mailbox
 * into Yioop and reports how far that copy has got.
 *
 * This component extends SocialComponent, the base every component
 * about a group extends, so no component calls another. It reads and
 * writes through MailAccountModel, MailScheduledModel and
 * MailSuppressionModel.
 *
 * AdminController and GroupController offer its userMail activity. A
 * controller names each activity it offers and the component that
 * carries it, so an activity named against the wrong component is not
 * found at run time.
 *
 * @author Chris Pollett
 */
class MailComponent extends SocialComponent
{
    /**
     * MAILSITE_ACCOUNT_ID sentinel account-id used for the synthetic per-user
     * MailSite mailbox that surfaces at the top of the sidebar when
     * MAILSITE_ENABLED. Real external accounts use auto-increment IDs starting
     * at 1, so 0 cannot collide. Routing handlers check (int) $account_id ===
     * self::MAILSITE_ACCOUNT_ID to branch into the MailSite-served paths
     * instead of opening an IMAP connection.
     */
    const MAILSITE_ACCOUNT_ID = 0;
    /**
     * MAILSITE_WINDOW default page size for the MailSite inbox listing. Matches
     * the IMAP-side window so the two paths render comparable first pages; the
     * inbox-load-more pagination patch will surface a follow-on window beyond
     * this.
     */
    const MAILSITE_WINDOW = 25;
    /**
     * MAIL_HEADER_MAX_LEN per-header byte cap. the standard numbered 5322
     * §2.1.1 limits a single
     * unfolded header line to 998 octets excluding the trailing CRLF. Used as
     * the clamp on raw header inputs (TO/CC/BCC/Subject) coming off the compose
     * form before further parsing.
     */
    const MAIL_HEADER_MAX_LEN = 998;
    /**
     * MAIL_ADDRESS_MAX_LEN per-address byte cap. the standard numbered 5321
     * §4.5.3.1.3 limits a
     * path (mailbox in <angle-brackets>) to 256 octets, and the bare mailbox
     * itself is bounded by 64 (local-part) + 1 + 255 (domain) = 320 octets.
     * Used as the validation ceiling for each entry in a parsed TO/CC/BCC list.
     */
    const MAIL_ADDRESS_MAX_LEN = 320;
    /**
     * MAIL_ATTACH_MAX_COUNT maximum number of attachments allowed on a single
     * compose submission. Arbitrary cap to prevent pathological cases (a UI bug
     * or malicious form replay sending thousands of one-byte attachments); 20
     * comfortably covers any legitimate mail use.
     */
    const MAIL_ATTACH_MAX_COUNT = 20;
    /**
     * MAIL_ATTACH_MAX_BYTES maximum byte size of any single attachment. 25 MB
     * matches Gmail's per-attachment limit and is what most receiver- side mail
     * servers accept without bouncing; sending attachments much larger than
     * this generally fails at the recipient's MTA, so capping here gives the
     * user immediate feedback rather than a 4xx SMTP error later.
     */
    const MAIL_ATTACH_MAX_BYTES = 25 * 1024 * 1024;
    /**
     * MAIL_BULK_MAX_COUNT maximum number of UIDs accepted in a single bulk-
     * action submission. High enough that any sane user-selected batch fits,
     * low enough to prevent pathological forged requests with tens of thousands
     * of UIDs from monopolising an IMAP connection. The UI shows the page's
     * worth of messages at a time (typically 50-100) so 500 leaves headroom for
     * "select all + load more" workflows.
     */
    const MAIL_BULK_MAX_COUNT = 500;
    /**
     * MAIL_FOLDER_NAME_MAX_LEN maximum length of a user-entered folder name.
     * IMAP itself does not formally cap mailbox name length, but in practice
     * most servers refuse names longer than around 255 octets; we cap a little
     * below that to leave room for the encoding overhead modified UTF-7 imposes
     * on non-ASCII names. Long enough for any legitimate user-chosen name.
     */
    const MAIL_FOLDER_NAME_MAX_LEN = 200;
    /**
     * MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN maximum length of a mail account's
     * user-facing display name. Same cap as folder names -- 200 is arbitrary
     * but generous; the DB column itself is much larger.
     */
    const MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN = 200;
    /**
     * MAIL_LOG_FIELD_MAX_LEN maximum length per logged key-value pair after
     * sanitization, so even an adversarial IMAP server response can't make one
     * mail.log line gigantic. Long values get truncated with a "…" suffix.
     */
    const MAIL_LOG_FIELD_MAX_LEN = 200;

    /**
     * addMailPrefData tells a group screen whether to draw the check box
     * that says a member wants mail from that group, and whether the box
     * is ticked. A member who has never chosen sees no box at all.
     * @param array &$data manageGroups view data
     * @param int $group_id group being shown
     */
    public function addMailPrefData(&$data, $group_id)
    {
        $mail_pref = $this->parent->model("group")->getMailSubscription(
            $_SESSION['USER_ID'], $group_id);
        $data['SHOW_MAIL_PREF'] = ($mail_pref !== null);
        $data['RECEIVE_GROUP_MAIL'] = $mail_pref;
    }

    /**
     * userMail draws the mail screens. Where a site has turned mail
     * off, it sends the user back to the account screen with a notice
     * saying so.
     *
     * Which screen it draws comes from the request. It draws the form
     * for adding a mail account, the form for editing one, or the
     * question before deleting one. It draws the list of messages in a
     * folder, or one message. Where the request asks for none of these
     * it draws an empty screen inviting the user to pick an account.
     *
     * After a form is saved, the user is sent back to that empty screen
     * with a notice, so the address in the browser holds no leftover of
     * the save.
     * @return mixed associative $data array prepared for MailElement, or the
     *     result of redirectWithMessage if mail is disabled, the user is not
     *     signed in, or a write operation just completed
     */
    public function userMail()
    {
        $parent = $this->parent;
        $mail_mode = C\nsdefined("MAIL_MODE") ? C\p('MAIL_MODE') : 'disabled';
        if ($mail_mode === 'disabled') {
            /* The address this came from asks for this same screen, so
               sending the browser back to it would ask again without
               end. The account screen is where a reader can go
               instead. */
            $_REQUEST['c'] = "admin";
            $_REQUEST['a'] = 'manageAccount';
            unset($_REQUEST['route']['c'], $_REQUEST['route']['a']);
            return $parent->redirectWithMessage(
                tl("social_component_mail_disabled"));
        }
        if (!isset($_SESSION['USER_ID'])) {
            $_REQUEST = ['c' => "admin", 'a' => '', C\p('CSRF_TOKEN') => ''];
            return $parent->redirectWithMessage(
                tl("social_component_login_first"));
        }
        $external_enabled = in_array($mail_mode,
            ['external_mail', 'both']);
        $mailsite_enabled = in_array($mail_mode,
            ['mailsite', 'both']);
        /* Message, folder and compose actions act on whichever
           mailbox the account_id names, including the synthetic
           MailSite account (id 0), so they are available whenever
           either mail kind is on. Only account-set management
           (add/edit/delete/clone/rename/reorder) stays external
           only, since the MailSite account has no DB row. */
        $mailbox_enabled = $external_enabled || $mailsite_enabled;
        $arg = $_REQUEST['arg'] ?? '';
        /* Each of these request arguments is answered by a
           method named for it: addAccount by
           userMailAddAccount, and so on. The first list needs an
           account the user set up at an outside mail provider;
           the second works on any mailbox, including the one
           this site keeps itself; the third needs that site's
           own mailbox. */
        $needs_account = ['addAccount', 'editAccount', 'deleteAccount',
            'startClone', 'cloneStatus', 'accountRename',
            'reorderAccounts'];
        $needs_mailbox = ['viewMessage', 'listMessages', 'bulkAction',
            'toggleFlag', 'folderAction', 'loadMessages',
            'toggleAccount', 'toggleFolderExpanded', 'listFolders',
            'compose', 'sendMessage', 'scheduledList'];
        $needs_mailsite = ['trustSender', 'untrustSender',
            'editMailsite', 'aliasAction'];
        $answered = (in_array($arg, $needs_account) &&
            $external_enabled) ||
            (in_array($arg, $needs_mailbox) && $mailbox_enabled) ||
            (in_array($arg, $needs_mailsite) && $mailsite_enabled);
        if ($answered) {
            $method = "userMail" . ucfirst($arg);
            return $this->$method();
        }
        if ($arg === 'cancelClone' && $external_enabled) {
            $data = $this->userMailBaseData();
            $job_id = $parent->clean(
                $_REQUEST['job_id'] ?? null, 'int', 0);
            if ($job_id > 0) {
                $clone_model = $parent->model("MailClone");
                $clone_model->cancelJob($job_id, $data["USER_ID"]);
            }
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_cancelled'));
        }
        if ($arg === 'retryClone' && $external_enabled) {
            $data = $this->userMailBaseData();
            $job_id = $parent->clean(
                $_REQUEST['job_id'] ?? null, 'int', 0);
            if ($job_id > 0) {
                $clone_model->retryJob($job_id, $data["USER_ID"]);
            }
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_retried'));
        }
        if (in_array($arg, ['downloadAttachment',
            'downloadAllAttachments'])
            && $mailbox_enabled) {
            return ($arg === 'downloadAllAttachments') ?
                $this->userMailDownloadAllAttachments() :
                $this->userMailDownloadAttachment();
        }
        if ($arg === 'scheduledCancel' && $mailbox_enabled) {
            $data = $this->userMailBaseData();
            $scheduled_id = $parent->clean(
                $_REQUEST['scheduled_id'] ?? null, 'int', 0);
            if ($scheduled_id > 0) {
                $scheduled_model = $parent->model("MailScheduled");
                $scheduled_model->deleteMessage($scheduled_id,
                    $data["USER_ID"]);
            }
            $_REQUEST['arg'] = 'scheduledList';
            return $parent->redirectWithMessage(
                tl("mail_element_scheduled_cancelled"),
                ['arg', 'account_id']);
        }
        /* explicit deselect (clicking outside any account on
           desktop, or the back button on mobile): forget the
           remembered active account and show the placeholder. */
        if (!empty($_REQUEST['deselect'])) {
            if (isset($_SESSION['MAIL_ACTIVE_ACCOUNT'])) {
                unset($_SESSION['MAIL_ACTIVE_ACCOUNT']);
                $parent->model("user")->setUserSession(
                    $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
                    $_SESSION);
            }
        }
        /* no explicit arg: if the session remembers an active
           account from a prior visit, jump straight to its inbox
           rather than the empty selectAccount placeholder. The
           MailSite synthetic account has id 0, so we use
           array_key_exists rather than ?? to tell "remembered as
           MailSite" apart from "never remembered". Real accounts
           are verified via getAccount so a stale or forged session
           pointer cannot exfiltrate another user's account.
           MailSite has no DB row; trust MAILSITE_ENABLED. Skipped
           on mobile, where the side list and the inbox are not both
           on screen: auto-jumping there would strand the user in an
           inbox with no visible way back to the account list. */
        if (array_key_exists('MAIL_ACTIVE_ACCOUNT', $_SESSION) &&
                $mailbox_enabled && empty($_SERVER['MOBILE'])) {
            $remembered = (int) $_SESSION['MAIL_ACTIVE_ACCOUNT'];
            $data = $this->userMailBaseData();
            $resolved = false;
            if ($remembered === self::MAILSITE_ACCOUNT_ID) {
                $resolved = !empty($data['MAILSITE_ENABLED']);
            } else if ($remembered > 0) {
                $account_model =
                    $parent->model("MailAccount");
                $account = $account_model->getAccount($remembered,
                    $data["USER_ID"]);
                $resolved = (bool) $account;
            }
            if ($resolved) {
                $_REQUEST['arg'] = 'listMessages';
                $_REQUEST['account_id'] = $remembered;
                return $this->userMailListMessages();
            }
        }
        $data = $this->userMailBaseData();
        $data["CONTENT_PANE"] = "selectAccount";
        return $data;
    }
    /**
     * userMailAddAccount handles GET (render Add Account form) and POST (insert
     * a new account row) for the external-IMAP add-account flow.
     * @return mixed $data for MailElement on GET, or redirectWithMessage on
     *     POST success
     */
    protected function userMailAddAccount()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $data["CONTENT_PANE"] = "addAccount";
        $data["FORM_VALUES"] = [
            "DISPLAY_NAME" => "",
            "PROVIDER" => "",
            "HOST" => "",
            "PORT" => 993,
            "USERNAME" => "",
            "TLS_MODE" => "imaps",
            "ALLOW_SELF_SIGNED" => 0,
            "SMTP_HOST" => "",
            "SMTP_PORT" => 587,
            "SMTP_USERNAME" => "",
            "SMTP_TLS_MODE" => "starttls",
        ];
        if (($_REQUEST['save'] ?? '') === 'save') {
            $fields = $this->userMailParseAccountFields();
            $errors = $this->userMailValidateAccountFields($fields, true);
            if (empty($errors)) {
                $account_model = $parent->model("MailAccount");
                $account_model->addAccount($data["USER_ID"], $fields);
                return $parent->redirectWithMessage(
                    tl("social_component_mail_account_saved"));
            }
            $data["FORM_VALUES"] = $fields;
            $data["FORM_ERRORS"] = $errors;
        }
        $this->userMailCleanAccountFormValues($data);
        return $data;
    }
    /**
     * userMailEditAccount handles GET (render Edit Account form) and POST
     * (update the row) for the external-IMAP edit-account flow. The password
     * field is rendered empty; submitting it empty preserves the stored
     * ciphertext, submitting it non-empty replaces it.
     * @return mixed $data for MailElement on GET, or redirectWithMessage on
     *     POST success
     */
    protected function userMailEditAccount()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        $account_model = $parent->model("MailAccount");
        $existing = $account_model->getAccount($account_id,
            $data["USER_ID"]);
        if (!$existing) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $data["CONTENT_PANE"] = "editAccount";
        $data["ACCOUNT_ID"] = $account_id;
        /*
            When a credential is already stored for a section, seed
            the password field with the masked sentinel so the edit
            form shows a fixed run of dots rather than an empty box.
            The real password is never decrypted into the form; the
            sentinel round-trips back as "leave unchanged".
         */
        $imap_password = empty($existing["HAS_PASSWORD"]) ? "" :
            MailAccountModel::PASSWORD_SENTINEL;
        $smtp_password = empty($existing["HAS_SMTP_PASSWORD"]) ? "" :
            MailAccountModel::PASSWORD_SENTINEL;
        $data["FORM_VALUES"] = [
            "DISPLAY_NAME" => $existing["DISPLAY_NAME"],
            "PROVIDER" => $existing["PROVIDER"] ?? '',
            "HOST" => $existing["HOST"],
            "PORT" => $existing["PORT"],
            "USERNAME" => $existing["USERNAME"],
            "PASSWORD" => $imap_password,
            "TLS_MODE" => $existing["TLS_MODE"],
            "ALLOW_SELF_SIGNED" => $existing["ALLOW_SELF_SIGNED"],
            "SMTP_HOST" => $existing["SMTP_HOST"],
            "SMTP_PORT" => $existing["SMTP_PORT"],
            "SMTP_USERNAME" => $existing["SMTP_USERNAME"],
            "SMTP_PASSWORD" => $smtp_password,
            "SMTP_TLS_MODE" => $existing["SMTP_TLS_MODE"],
        ];
        if (($_REQUEST['save'] ?? '') === 'save') {
            $fields = $this->userMailParseAccountFields();
            $errors = $this->userMailValidateAccountFields($fields, false);
            if (empty($errors)) {
                $leave_password = empty($fields['PASSWORD']);
                $leave_smtp_password = empty($fields['SMTP_PASSWORD']);
                $account_model->updateAccount($account_id,
                    $data["USER_ID"], $fields, $leave_password,
                    $leave_smtp_password);
                return $parent->redirectWithMessage(
                    tl("social_component_mail_account_saved"));
            }
            $data["FORM_VALUES"] = $fields;
            /*
                userMailParseAccountFields() normalized an untouched
                password field to "". On an error re-render, restore
                the masked sentinel for any section that still has a
                stored credential so the dots survive the bounce
                rather than the field going blank.
             */
            if ($fields['PASSWORD'] === '' &&
                !empty($existing["HAS_PASSWORD"])) {
                $data["FORM_VALUES"]['PASSWORD'] =
                    MailAccountModel::PASSWORD_SENTINEL;
            }
            if ($fields['SMTP_PASSWORD'] === '' &&
                !empty($existing["HAS_SMTP_PASSWORD"])) {
                $data["FORM_VALUES"]['SMTP_PASSWORD'] =
                    MailAccountModel::PASSWORD_SENTINEL;
            }
            $data["FORM_ERRORS"] = $errors;
        }
        /* Surface the active clone-job row (if any) and the
           MailClone enabled-toggle so the view can render the
           Clone button in one of three states: active button,
           greyed-out (toggle disabled), and hidden (no MailSite
           mode). The MachineModel job-status read is a small
           file_get_contents so doing it here is cheap. */
        $machine_model = $parent->model("Machine");
        $data["MAILCLONE_ENABLED"] =
            (bool) $machine_model->getJobStatus('MailClone');
        /* Cheap glob over the schedules lock-files; surfaces
           whether MediaUpdater and MailServer are live so the
           clone status banner can show a red warning if either
           is down. Re-checked on every status-poll by the JS
           script. */
        $daemons = L\CrawlDaemon::statuses();
        $data["MEDIA_UPDATER_RUNNING"] =
            !empty($daemons['MediaUpdater']);
        $data["MAIL_SERVER_RUNNING"] =
            !empty($daemons['MailServer']);
        /* pane=clone on the editAccount URL switches the view
           from the settings form to the clone form. The link sits
           below the Name field on the settings form; the
           closeHelper [x] rendered on the clone form points back
           at the same URL without pane=clone. */
        $data["CLONE_PANE_ACTIVE"] =
            (($_REQUEST['pane'] ?? '') === 'clone');
        $this->userMailCleanAccountFormValues($data);
        return $data;
    }
    /**
     * userMailCleanAccountFormValues makes the words a user typed into
     * the account form safe to put back in the form. A user may type a
     * quotation mark or an angle bracket in a name or an address, and
     * those characters would end the form field early and let the rest
     * be read as markup. This turns each into the writing that stands
     * for it.
     *
     * Only the fields a user types words into are changed. A port
     * number is written back as a number, and the choice of how to
     * encrypt is written back from a fixed set, so neither can carry
     * such a character. A password field carries either nothing or a
     * row of stars standing for the password already kept.
     * @param array $data the mail $data array; $data['FORM_VALUES'] is cleaned
     *     in place when present
     */
    protected function userMailCleanAccountFormValues(&$data)
    {
        if (empty($data["FORM_VALUES"])) {
            return;
        }
        $parent = $this->parent;
        $clean_fields = ['DISPLAY_NAME', 'HOST', 'USERNAME',
            'SMTP_HOST', 'SMTP_USERNAME'];
        foreach ($clean_fields as $form_field) {
            if (isset($data["FORM_VALUES"][$form_field])) {
                $data["FORM_VALUES"][$form_field] = $parent->clean(
                    $data["FORM_VALUES"][$form_field], "string", "");
            }
        }
    }
    /**
     * userMailStartClone submits a new MAIL_CLONE_JOB row. The clone-job copies
     * messages from an external IMAP account the user owns into the user's
     * local MailSite mailbox. Validates ownership of the source account,
     * presence of MailSite (mode=mailsite or both), single-job-per-user, and
     * MODE token (add or wipe). On success, inserts a pending row -- the
     * MediaUpdater's MailCloneJob tick picks it up on the next pass and starts
     * actually copying messages.
     * @return mixed redirectWithMessage result
     */
    protected function userMailStartClone()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        if (empty($data["MAILSITE_ENABLED"])) {
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_mailsite_required'));
        }
        $machine_model = $parent->model("Machine");
        if (!$machine_model->getJobStatus('MailClone')) {
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_job_disabled'));
        }
        /* MediaUpdater and MailServer must both be live for the
           clone to make progress: MediaUpdater dispatches the
           tick, MailServer hosts the destination MailSite that
           appendMessage talks to. CrawlDaemon::statuses globs
           the schedules lock-file directory; the cost is one
           glob + a stat per file (cheap; an inflight worker
           refreshes its lock-file mtime each pass). When either
           is down we refuse the submission rather than insert a
           row that will sit pending until someone notices. */
        $daemons = L\CrawlDaemon::statuses();
        if (empty($daemons['MediaUpdater'])) {
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_updater_down'));
        }
        if (empty($daemons['MailServer'])) {
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_server_down'));
        }
        $account_model = $parent->model("MailAccount");
        $existing = $account_model->getAccount($account_id,
            $data["USER_ID"]);
        if (!$existing) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $clone_mode = $_REQUEST['clone_mode'] ?? 'add';
        if (!in_array($clone_mode,
                ['add', 'wipe', 'dry_run'], true)) {
            $clone_mode = 'add';
        }
        /* The mode form has three mutually-exclusive radios:
           add, wipe, and dry_run. Internally we store MODE as
           either 'add' or 'wipe' (wipe + dry_run combined would
           be incoherent and is unreachable via this UI), and
           split out dry_run as the separate persistence flag the
           MailCloneJob already understands. */
        $dry_run = ($clone_mode === 'dry_run');
        $mode = ($clone_mode === 'wipe') ? 'wipe' : 'add';
        /* Newest-N-per-folder cap from the dropdown. Only the
           offered values are honored -- 0 meaning ALL (no cap) --
           and anything else (or a missing field) falls back to 0
           so a tampered or absent value cannot smuggle in an odd
           limit. */
        $folder_cap = (int) ($_REQUEST['folder_cap'] ?? 0);
        if (!in_array($folder_cap,
                [0, 1000, 5000, 10000, 50000], true)) {
            $folder_cap = 0;
        }
        $clone_model = $parent->model("MailClone");
        $existing_job = $clone_model->activeJobForUser(
            $data["USER_ID"]);
        if ($existing_job) {
            return $parent->redirectWithMessage(tl(
                'social_component_mail_clone_already_running'));
        }
        $destination_user = $data["USER_NAME"];
        $clone_model->insertJob($data["USER_ID"], $account_id,
            $destination_user, $mode, $dry_run, $folder_cap);
        /* Redirect back to the editAccount + pane=clone URL the
           user came from. The view sees the now-active job
           (populated in userMailBaseData) and renders the
           status banner instead of the form. The redirect
           helper preserves only fields named in copy_fields
           plus a default set (c, a, CSRF token, ...); pre-set
           arg/account_id/pane on $_REQUEST so they survive. */
        $_REQUEST['arg'] = 'editAccount';
        $_REQUEST['account_id'] = $account_id;
        $_REQUEST['pane'] = 'clone';
        return $parent->redirectWithMessage(tl(
            'social_component_mail_clone_started'),
            ['arg', 'account_id', 'pane']);
    }
    /**
     * userMailCloneStatus returns the current user's active clone-job row as
     * JSON, or null when no job is pending or running. Polled by the status-
     * banner JS every five seconds; the response shape matches the banner's
     * rendering needs (STATUS, IMPORTED, SKIPPED, FAILED, CURRENT_FOLDER,
     * LAST_ERROR) without leaking back-end internals (full row keys, ownership,
     * raw timestamps).
     */
    protected function userMailCloneStatus()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $clone_model = $parent->model("MailClone");
        $row = $clone_model->activeJobForUser($data["USER_ID"]);
        $parent->web_site->header(
            'Content-Type: application/json');
        $daemons = L\CrawlDaemon::statuses();
        $payload = [
            'active' => (bool) $row,
            'media_updater_running' =>
                !empty($daemons['MediaUpdater']),
            'mail_server_running' =>
                !empty($daemons['MailServer']),
        ];
        if ($row) {
            $payload['id'] = (int) $row['ID'];
            $payload['status'] = $row['STATUS'];
            $payload['mode'] = $row['MODE'];
            $payload['current_folder'] = $row['CURRENT_FOLDER'];
            $payload['imported'] = (int) $row['IMPORTED'];
            $payload['skipped'] = (int) $row['SKIPPED'];
            /* The screen counts the errors that are still open, so
                the number it shows agrees with the list of errors
                below it. */
            $payload['failed'] = $clone_model
                ->unresolvedErrorCount((int) $row['ID']);
            /* The newest fifty errors, newest first. Fifty keeps the
                reply small; older errors stay in the database. */
            $error_rows = $clone_model->recentErrors(
                (int) $row['ID'], 50);
            $payload['errors'] = [];
            foreach ($error_rows as $error_row) {
                $payload['errors'][] = [
                    'folder' => $error_row['FOLDER'],
                    'uidvalidity' =>
                        (int) $error_row['SRC_UIDVALIDITY'],
                    'uid' => (int) $error_row['SRC_UID'],
                    'kind' => $error_row['ERROR_KIND'],
                    'message' => $error_row['MESSAGE'],
                    'occurred_at' =>
                        (int) $error_row['OCCURRED_AT'],
                    'resolved_at' =>
                        (int) $error_row['RESOLVED_AT'],
                    'resolved_via' =>
                        $error_row['RESOLVED_VIA'] ?? '',
                ];
            }
        }
        e(json_encode($payload));
        \seekquarry\atto\webExit();
    }
    /**
     * userMailDeleteAccount deletes an account row owned by the current user.
     * Returns the default content pane via redirectWithMessage so the success
     * notice shows.
     * @return mixed redirectWithMessage result
     */
    protected function userMailDeleteAccount()
    {
        $parent = $this->parent;
        $user_id = $_SESSION['USER_ID'];
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        if ($account_id > 0) {
            $account_model = $parent->model("MailAccount");
            $account_model->deleteAccount($account_id, $user_id);
            /* drop per-account session caches for this account
               so neither the folder list nor the active-account
               pointer survive the delete. */
            $parent->model("Mail")->invalidateFolders($account_id);
            unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
            unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]);
            unset($_SESSION["MAIL_ACCOUNT_COLLAPSED"][$account_id]);
            if ((int) ($_SESSION['MAIL_ACTIVE_ACCOUNT'] ?? 0) ===
                $account_id) {
                unset($_SESSION['MAIL_ACTIVE_ACCOUNT']);
            }
            $parent->model("user")->setUserSession(
                $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
                $_SESSION);
        }
        return $parent->redirectWithMessage(
            tl("social_component_mail_account_deleted"));
    }
    /**
     * userMailListMessages connects to the account's IMAP server, runs LOGIN +
     * SELECT + FETCH 1:N (envelope only, no body) and prepares $data for the
     * inbox listing view. Errors during connect / login surface as a flash
     * message and the user is bounced back to the default content pane.
     * @return mixed $data for MailElement, or redirectWithMessage on connect
     *     failure
     */
    protected function userMailListMessages()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage());
        }
        /* remember the active account in the session so the next
           visit to user_mail (no explicit account_id, e.g. from
           the main nav after a detour through another activity)
           re-opens this inbox instead of the empty placeholder.
           the DB-backed user session is flushed only when the
           pointer actually changes, so this handler doesn't pay
           a write cost on every inbox refresh. */
        $previous_active =
            (int) ($_SESSION['MAIL_ACTIVE_ACCOUNT'] ?? 0);
        $_SESSION['MAIL_ACTIVE_ACCOUNT'] = $account_id;
        if ($previous_active !== $account_id) {
            $parent->model("user")->setUserSession(
                $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
                $_SESSION);
        }
        $data["CONTENT_PANE"] = "inbox";
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
            $backend->displayName(), "string", "");
        $requested_folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $default_folder = $backend->defaultFolder();
        $folder = $requested_folder !== "" ? $requested_folder :
            $default_folder;
        $data["ACTIVE_FOLDER"] = $folder;
        $data["FOLDERS"] = [];
        try {
            $mail_model = $parent->model("Mail");
            $cached_folders = $mail_model->cachedFolders($account_id);
            if ($cached_folders !== null) {
                $data["FOLDERS"] = $cached_folders;
                $backend->annotateUnreadCounts($data["FOLDERS"]);
            } else {
                $data["FOLDERS"] = $backend->listFolders();
                $backend->annotateUnreadCounts($data["FOLDERS"]);
                $mail_model->cacheFolders($account_id,
                    $data["FOLDERS"]);
            }
            $this->userMailDecorateFolderDisplay($data);
            $filter = $this->userMailGetFilter();
            $unread_only = $this->userMailGetUnreadOnly();
            $flagged_only = $this->userMailGetFlaggedOnly();
            $sort = $this->userMailGetSort($account_id, $folder);
            $result = $backend->listMessages($folder, [
                'window' => self::MAILSITE_WINDOW,
                'sort' => $sort,
                'filter' => $filter,
                'unread_only' => $unread_only,
                'flagged_only' => $flagged_only,
                'unreadable_subject' =>
                    tl('mail_element_unreadable_message'),
                'sort_supported' =>
                    $mail_model->sortSupportHint($account_id),
                'cursor' => null]);
            $mail_model->cacheSortSupport($account_id,
                $result['sort_supported']);
            $data["MESSAGES"] =
                $this->userMailCleanEnvelopes($result['messages']);
            $data["TOTAL_MESSAGES"] = $result['total'];
            $data["SORT"] = $result['sort'];
            $data["SORT_SUPPORTED"] = $result['sort_supported'];
            $data["SORT_OVERSIZED"] =
                !empty($result['sort_oversized']);
            $data["SORT_DATE_ONLY"] =
                !empty($result['sort_date_only']);
            $data["SORT_MODE"] = $result['mode'];
            $next_cursor = $result['next_cursor'];
            /* OLDEST_SEQ drives the range-mode "load more" (the next
               page is the messages below this sequence); SORT_OFFSET
               drives the sorted-mode one (continue this many rows
               into the ordered list). The other is zero. */
            $data["OLDEST_SEQ"] = ($result['mode'] === 'range' &&
                $next_cursor) ? (int) $next_cursor['before'] : 0;
            $data["SORT_OFFSET"] = ($result['mode'] === 'sorted' &&
                $next_cursor) ? (int) $next_cursor['offset'] :
                count($result['messages']);
            $mail_model->cacheSequence($account_id, $folder,
                $filter, $sort, $unread_only, $flagged_only,
                $next_cursor);
            $data["FILTER"] = $parent->clean($filter, "string", "");
            $data["UNREAD_ONLY"] = $unread_only;
            $data["FLAGGED_ONLY"] = $flagged_only;
        } catch (ML\MailBackendException $exception) {
            $data["IMAP_ERROR"] = $parent->clean(
                $exception->getMessage(), "string", "");
            $data["MESSAGES"] = [];
        } finally {
            $backend->close();
        }
        return $data;
    }
    /**
     * userMailLoadMessages infinite-scroll endpoint for the inbox listing.
     * Fetches the window of message envelopes immediately older than the
     * sequence number the client already has, renders them to an HTML fragment
     * using the same row markup as the initial page, and returns a small JSON
     * payload the mailmessages.js handler appends to the list. Always emits
     * JSON and exits; it never returns $data for a view.
     * @return void emits a JSON response and exits
     */
    protected function userMailLoadMessages()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        /* this handler emits JSON and exits, so it never returns to
           GroupController, which is what injects the CSRF token on
           the normal page-render path; set it here so the message
           links built by MailElement::renderInboxRow are valid. */
        $data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken(
            $data["USER_ID"]);
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        $mode = $parent->clean($_REQUEST['mode'] ?? null,
            ['range', 'sorted'], 'range');
        $before_seq = $parent->clean($_REQUEST['before_seq'] ?? null, 'int', 0);
        $offset = $parent->clean($_REQUEST['offset'] ?? null, 'int', 0);
        $limit = $parent->clean($_REQUEST['limit'] ?? null, 'int', 25);
        if ($limit < 1 || $limit > 100) {
            $limit = 25;
        }
        $parent->web_site->header('Content-Type: application/json');
        $valid = ($mode === 'range' ? $before_seq >= 2 :
            $offset >= 1);
        if (!$valid) {
            e(json_encode(['error' => 'invalid_request',
                'html' => '', 'message_count' => 0,
                'has_more' => false]));
            \seekquarry\atto\webExit();
        }
        $folder = $parent->clean($_REQUEST['folder'] ?? null,
            'imap_arg', '');
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
        } catch (ML\MailBackendException $exception) {
            e(json_encode(['error' => 'invalid_request',
                'html' => '', 'message_count' => 0,
                'has_more' => false]));
            \seekquarry\atto\webExit();
        }
        if ($folder === "") {
            $folder = $backend->defaultFolder();
        }
        /* renderInboxRow builds per-message viewMessage links and
           wants to thread the active folder through them so the
           Back link returns to the same folder. */
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACTIVE_FOLDER"] = $folder;
        try {
            $mail_model = $parent->model("Mail");
            $sort = $this->userMailGetSort($account_id, $folder);
            $filter = $this->userMailGetFilter();
            $unread_only = $this->userMailGetUnreadOnly();
            $flagged_only = $this->userMailGetFlaggedOnly();
            if ($mode === 'sorted') {
                $signature = ImapListing::sortSignature($sort,
                    $unread_only, $flagged_only);
                $sequence = $mail_model->cachedSequence($account_id,
                    $folder, $filter, $signature);
                $cursor = ['mode' => 'sorted', 'offset' => $offset,
                    'sequence' => $sequence];
            } else {
                $cursor = ['mode' => 'range', 'before' => $before_seq];
            }
            $result = $backend->listMessages($folder, [
                'window' => $limit,
                'sort' => $sort,
                'filter' => $filter,
                'unread_only' => $unread_only,
                'flagged_only' => $flagged_only,
                'unreadable_subject' =>
                    tl('mail_element_unreadable_message'),
                'sort_supported' =>
                    $mail_model->sortSupportHint($account_id),
                'cursor' => $cursor]);
            $messages =
                $this->userMailCleanEnvelopes($result['messages']);
            $has_more = $result['has_more'];
            $next_cursor = $result['next_cursor'];
            $next_oldest_seq = ($result['mode'] === 'range' &&
                $next_cursor) ? (int) $next_cursor['before'] : 0;
            $next_offset = ($result['mode'] === 'sorted' &&
                $next_cursor) ? (int) $next_cursor['offset'] : 0;
        } catch (\Exception $exception) {
            $backend->close();
            e(json_encode(['error' => 'imap_error',
                'html' => '', 'message_count' => 0,
                'has_more' => false]));
            \seekquarry\atto\webExit();
        }
        $backend->close();
        $html = "";
        foreach ($messages as $msg) {
            $html .= MailElement::renderInboxRow($data, $msg);
        }
        e(json_encode([
            'html' => $html,
            'message_count' => count($messages),
            'has_more' => $has_more,
            'oldest_seq' => $next_oldest_seq,
            'offset' => $next_offset
        ]));
        \seekquarry\atto\webExit();
    }
    /**
     * userMailToggleAccount records whether an account's folder list is
     * collapsed or expanded in the account pane. Emits a small JSON
     * acknowledgement and exits; the disclosure triangle in MailElement is
     * toggled on the client, and this only persists the new state in the
     * session so it survives later page loads. The account is verified to
     * belong to the signed-in user before anything is stored.
     * @return void this handler emits JSON and exits
     */
    protected function userMailToggleAccount()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $parent->web_site->header('Content-Type: application/json');
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        $collapsed = $parent->clean($_REQUEST['collapsed'] ?? null, 'bool');
        if (!($data["MAILSITE_ENABLED"] &&
                $account_id === self::MAILSITE_ACCOUNT_ID)) {
            $account_model = $parent->model("MailAccount");
            $account = $account_model->getAccount($account_id,
                $data["USER_ID"]);
            if (!$account) {
                e(json_encode(['error' => 'invalid_request']));
                \seekquarry\atto\webExit();
            }
        }
        if (empty($_SESSION["MAIL_ACCOUNT_COLLAPSED"])) {
            $_SESSION["MAIL_ACCOUNT_COLLAPSED"] = [];
        }
        $_SESSION["MAIL_ACCOUNT_COLLAPSED"][$account_id] = $collapsed;
        $parent->model("user")->setUserSession(
            $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID, $_SESSION);
        e(json_encode(['status' => 'OK',
            'account_id' => $account_id, 'collapsed' => $collapsed]));
        \seekquarry\atto\webExit();
    }
    /**
     * userMailReorderAccounts persists the user's drag-chosen ordering of their
     * mail accounts. Expects an 'order' request parameter: a comma-separated
     * list of account ids in the desired display order. The synthetic local
     * MailSite account is pinned and not reorderable, so its id is dropped from
     * the list before the order is stored. The model scopes every update to the
     * signed-in user, so ids belonging to another user (or the MailSite id)
     * change nothing. Returns a small JSON status.
     */
    protected function userMailReorderAccounts()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $parent->web_site->header('Content-Type: application/json');
        $order_raw = $parent->clean($_REQUEST['order'] ?? null,
            'string', '');
        $ordered_ids = [];
        foreach (explode(',', $order_raw) as $piece) {
            $piece = trim($piece);
            if ($piece === '' || !ctype_digit($piece)) {
                continue;
            }
            $account_id = (int) $piece;
            if ($account_id === self::MAILSITE_ACCOUNT_ID) {
                continue;
            }
            $ordered_ids[] = $account_id;
        }
        if (empty($ordered_ids)) {
            e(json_encode(['error' => 'invalid_request']));
            \seekquarry\atto\webExit();
        }
        $account_model = $parent->model("MailAccount");
        $account_model->updateAccountOrder($data["USER_ID"],
            $ordered_ids);
        e(json_encode(['status' => 'OK',
            'order' => $ordered_ids]));
        \seekquarry\atto\webExit();
    }
    /**
     * userMailToggleFolderExpanded persists a per-folder expand/collapse state
     * in the session so the side-panel disclosure arrows render the same way on
     * the next page load. Expected request parameters: account_id (int), folder
     * (string, the folder full path), expanded (bool). Verifies the account
     * belongs to the signed-in user before storing. The session map is sparse:
     * only folders that the user has explicitly expanded are stored. When a
     * folder is collapsed its entry is removed so the map does not grow
     * indefinitely with stale "explicitly collapsed" entries -- absent means
     * collapsed (the page-load default).
     * @return void this handler emits JSON and exits
     */
    protected function userMailToggleFolderExpanded()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $parent->web_site->header('Content-Type: application/json');
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $expanded = (bool) $parent->clean(
            $_REQUEST['expanded'] ?? null, 'bool', false);
        if (!($data["MAILSITE_ENABLED"] &&
                $account_id === self::MAILSITE_ACCOUNT_ID)) {
            $account_model = $parent->model("MailAccount");
            $account = $account_model->getAccount($account_id,
                $data["USER_ID"]);
            if (!$account || $folder === '') {
                e(json_encode(['error' => 'invalid_request']));
                \seekquarry\atto\webExit();
            }
        } else if ($folder === '') {
            e(json_encode(['error' => 'invalid_request']));
            \seekquarry\atto\webExit();
        }
        if (empty($_SESSION["MAIL_FOLDER_EXPANDED"])) {
            $_SESSION["MAIL_FOLDER_EXPANDED"] = [];
        }
        if (empty($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id])) {
            $_SESSION["MAIL_FOLDER_EXPANDED"][$account_id] = [];
        }
        if ($expanded) {
            $_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]
                [$folder] = true;
        } else {
            unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]
                [$folder]);
        }
        $parent->model("user")->setUserSession(
            $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID, $_SESSION);
        e(json_encode(['status' => 'OK',
            'account_id' => $account_id,
            'folder' => $folder,
            'expanded' => $expanded]));
        \seekquarry\atto\webExit();
    }
    /**
     * userMailListFolders fetches the IMAP folder list for an account on demand
     * and returns its rendered HTML rows as JSON, for the JS lazy- load that
     * fires when a user first expands an account in the side column. Verifies
     * account ownership, opens IMAP, runs LIST only (no SELECT, no FETCH) so
     * the call is cheap, parses and sorts the folders, caches them in the
     * session so subsequent page renders see them, then renders the folder
     * items the same way renderUserAccounts does and returns the HTML fragment.
     * On any IMAP error the response carries status 'error' and an empty html
     * string so the caller can leave the disclosure empty without special-
     * casing the failure.
     * @return void this handler emits JSON and exits
     */
    protected function userMailListFolders()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        /* this handler emits JSON and exits, so it never returns
           to GroupController, which is what injects the CSRF
           token on the normal page-render path; set it here so
           the folder links built by renderFolderItem are valid. */
        $data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken(
            $data["USER_ID"]);
        $parent->web_site->header('Content-Type: application/json');
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
        $folders = [];
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $mail_model = $parent->model("Mail");
            $cached_folders = $mail_model->cachedFolders($account_id);
            if ($cached_folders !== null) {
                $folders = $cached_folders;
            } else {
                $folders = $backend->listFolders();
                $backend->annotateUnreadCounts($folders);
                $mail_model->cacheFolders($account_id, $folders);
            }
        } catch (ML\MailBackendException $exception) {
            echo json_encode(['status' => 'error',
                'account_id' => $account_id, 'html' => '']);
            \seekquarry\atto\webExit();
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
        $data["ACCOUNT_ID"] = $account_id;
        $data["FOLDERS"] = $folders;
        $this->userMailDecorateFolderDisplay($data);
        ob_start();
        MailElement::renderFolderTree($data, $account_id,
            $data["FOLDERS"], false, '');
        $html = ob_get_clean();
        echo json_encode(['status' => 'OK',
            'account_id' => $account_id, 'html' => $html]);
        \seekquarry\atto\webExit();
    }
    /**
     * userMailCompose renders the Compose Message form. On a fresh GET the form
     * is blank with To focused. On a re-render after a failed sendMessage POST
     * (validation error or SMTP failure) the form is repopulated with the
     * user's typed values and a per-field or top-level error message is shown
     * so they can fix and retry without losing their work.
     * @return array $data for MailElement with CONTENT_PANE set to "compose"
     */
    protected function userMailCompose()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $account = null;
        if ((int) $account_id === self::MAILSITE_ACCOUNT_ID) {
            if (empty($data['MAILSITE_ENABLED'])) {
                return $parent->redirectWithMessage(
                    tl("social_component_invalid_account"));
            }
        } else {
            $account_model = $parent->model("MailAccount");
            $account = $account_model->getAccount($account_id,
                $data["USER_ID"]);
            if (!$account) {
                return $parent->redirectWithMessage(
                    tl("social_component_invalid_account"));
            }
        }
        /* the userMailBaseData ACCOUNTS list carries every
           sender identity available to this user (MailSite if
           enabled, plus every IMAP row); look up the display
           name there so the compose pane and the from-account
           dropdown read from one source of truth. */
        $display_name = '';
        foreach ($data['ACCOUNTS'] as $entry) {
            if ((int) $entry['ID'] === (int) $account_id) {
                $display_name = $entry['DISPLAY_NAME'] ?? '';
                break;
            }
        }
        $data["CONTENT_PANE"] = "compose";
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACCOUNT_DISPLAY_NAME"] = $display_name;
        $data["FROM_DISPLAY"] = ($account !== null) ?
            $this->userMailFormatFrom($account) : $display_name;
        $data["FROM_OPTIONS"] = $this->userMailFromOptions($data);
        $data["FROM_VALUE"] = $account_id . '|' .
            $parent->clean($data["FROM_DISPLAY"], "string", "");
        $form_values = [
            "TO" => "",
            "CC" => "",
            "BCC" => "",
            "SUBJECT" => "",
            "BODY" => "",
        ];
        $reply_context = ['KIND' => '', 'UID' => 0,
            'FOLDER' => '', 'MESSAGE_ID' => '', 'REFERENCES' => '',
            'SOURCE_ACCOUNT_ID' => $account_id];
        $reply_kind = $parent->clean(
            $_REQUEST['reply_kind'] ?? null, 'string', '');
        $reply_uid = $parent->clean(
            $_REQUEST['reply_uid'] ?? null, 'int', 0);
        $reply_folder = $parent->clean(
            $_REQUEST['reply_folder'] ?? null, 'imap_arg', '');
        if (in_array($reply_kind,
                ['reply', 'reply_all', 'forward'], true) &&
                $reply_uid > 0 && $reply_folder !== '') {
            $prefill = $this->userMailBuildReplyPrefill(
                $account_id, $data, $reply_kind, $reply_folder,
                $reply_uid);
            if ($prefill !== null) {
                $form_values = array_merge($form_values,
                    $prefill['FORM_VALUES']);
                $prefill_context = $prefill['CONTEXT'];
                $prefill_context['SOURCE_ACCOUNT_ID'] = $account_id;
                $reply_context = $prefill_context;
            }
        }
        $data["FORM_VALUES"] = $form_values;
        $data["REPLY_CONTEXT"] = $reply_context;
        $data["FORM_ERRORS"] = [];
        $this->userMailCleanComposeValues($data);
        return $data;
    }
    /**
     * userMailCleanComposeValues hTML-cleans the compose-pane values the view
     * echoes into input value attributes / hidden fields / the body textarea,
     * so renderCompose can emit them raw. Covers the FORM_VALUES the user (or a
     * reply prefill) supplies and the free-text REPLY_CONTEXT header fields;
     * REPLY_CONTEXT KIND is a fixed token and the UID / account-id members are
     * ints, so they are left untouched, as is FROM_DISPLAY which is cleaned
     * where it is assigned.
     * @param array $data the mail $data array; FORM_VALUES and REPLY_CONTEXT
     *     are cleaned in place when present
     */
    protected function userMailCleanComposeValues(&$data)
    {
        $parent = $this->parent;
        if (!empty($data["FORM_VALUES"])) {
            foreach (['TO', 'CC', 'BCC', 'SUBJECT', 'BODY']
                    as $compose_field) {
                if (isset($data["FORM_VALUES"][$compose_field])) {
                    $data["FORM_VALUES"][$compose_field] =
                        $parent->clean(
                        $data["FORM_VALUES"][$compose_field],
                        "string", "");
                }
            }
        }
        if (!empty($data["REPLY_CONTEXT"])) {
            foreach (['FOLDER', 'MESSAGE_ID', 'REFERENCES']
                    as $reply_field) {
                if (isset($data["REPLY_CONTEXT"][$reply_field])) {
                    $data["REPLY_CONTEXT"][$reply_field] =
                        $parent->clean(
                        $data["REPLY_CONTEXT"][$reply_field],
                        "string", "");
                }
            }
        }
    }
    /**
     * userMailSendMessage handles the Compose form POST: reads the To / Subject
     * / Body fields, sanitizes them (strip CRLF from the header fields to
     * defeat header injection, enforce the body-size cap), and on success hands
     * the message to SmtpClient for transmission via the account's
     * SMTP_HOST/PORT/USERNAME/PASSWORD. On validation error or SMTP failure the
     * form is re-rendered with values preserved and the error displayed; on
     * success the user is redirected back to the inbox with a "Message sent."
     * flash.
     * @return mixed $data for MailElement on failure (re-render), or a redirect
     *     on success
     */
    protected function userMailSendMessage()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        /* The single From dropdown submits from_option as
           "<account_id>|<address>". Split it into the account_id
           and from_identity the rest of the flow already reads, so
           the user sends from the chosen account and, for the local
           account, as the chosen address. The address is validated
           later against the identities the user owns. */
        $from_option = $parent->clean(
            $_REQUEST['from_option'] ?? "", "string", "");
        if ($from_option !== "" && strpos($from_option, '|') !==
            false) {
            list($option_account, $option_address) =
                explode('|', $from_option, 2);
            $_REQUEST['account_id'] = (int) $option_account;
            $_REQUEST['from_identity'] = $option_address;
        }
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $account = null;
        if ((int) $account_id === self::MAILSITE_ACCOUNT_ID) {
            if (empty($data['MAILSITE_ENABLED'])) {
                return $parent->redirectWithMessage(
                    tl("social_component_invalid_account"));
            }
        } else {
            $account_model = $parent->model("MailAccount");
            $account = $account_model->getAccountForSending(
                $account_id, $data["USER_ID"]);
            if (!$account) {
                return $parent->redirectWithMessage(
                    tl("social_component_invalid_account"));
            }
        }
        /* RFC 5322 §2.1.1 limits one header line to 998 octets
           (MAIL_HEADER_MAX_LEN). The per-address ceiling
           (MAIL_ADDRESS_MAX_LEN, 320 octets per RFC 5321) is
           applied after splitting comma-separated lists. The
           body cap is MAX_MAIL_BODY_LEN from Config. CRLF is
           stripped from headers to defeat header injection; the
           body keeps its CRLF since line breaks are legitimate
           there. */
        $raw_to = MailHeaderParser::cleanHeader(
            $_REQUEST['to'] ?? '', self::MAIL_HEADER_MAX_LEN);
        $raw_cc = MailHeaderParser::cleanHeader(
            $_REQUEST['cc'] ?? '', self::MAIL_HEADER_MAX_LEN);
        $raw_bcc = MailHeaderParser::cleanHeader(
            $_REQUEST['bcc'] ?? '', self::MAIL_HEADER_MAX_LEN);
        $subject = MailHeaderParser::cleanHeader(
            $_REQUEST['subject'] ?? '', self::MAIL_HEADER_MAX_LEN);
        $raw_body = (string) ($_REQUEST['body'] ?? '');
        $errors = [];
        $to_addresses = MailHeaderParser::parseAddressList($raw_to);
        $cc_addresses = MailHeaderParser::parseAddressList($raw_cc);
        $bcc_addresses = MailHeaderParser::parseAddressList($raw_bcc);
        /* Somebody writing to a person on the same site types the name
           alone. The site's own mail domain is put on such a name before
           anything is checked, so it is sent to rather than turned away,
           and so the completed address is what the person sees where the
           form comes back. */
        $local_domain = ML\MailSiteFactory::localDomains()[0] ?? "";
        foreach ([&$to_addresses, &$cc_addresses, &$bcc_addresses]
            as &$address_list) {
            foreach ($address_list as &$one_address) {
                $one_address = MailHeaderParser::completeLocalAddress(
                    $one_address, $local_domain);
            }
            unset($one_address);
        }
        unset($address_list);
        if (empty($to_addresses)) {
            $errors['TO'] = tl('mail_element_field_to_required');
        } else {
            foreach ($to_addresses as $addr) {
                $bare = MailHeaderParser::extractBareAddress($addr);
                if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
                    !MailHeaderParser::isValidAddress($bare)) {
                    $errors['TO'] = tl(
                        'mail_element_field_address_invalid', $addr);
                    break;
                }
            }
        }
        if (empty($errors['TO']) && !empty($cc_addresses)) {
            foreach ($cc_addresses as $addr) {
                $bare = MailHeaderParser::extractBareAddress($addr);
                if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
                    !MailHeaderParser::isValidAddress($bare)) {
                    $errors['CC'] = tl(
                        'mail_element_field_address_invalid', $addr);
                    break;
                }
            }
        }
        if (empty($errors['CC']) && !empty($bcc_addresses)) {
            foreach ($bcc_addresses as $addr) {
                $bare = MailHeaderParser::extractBareAddress($addr);
                if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
                    !MailHeaderParser::isValidAddress($bare)) {
                    $errors['BCC'] = tl(
                        'mail_element_field_address_invalid', $addr);
                    break;
                }
            }
        }
        if (strlen($raw_body) > C\MAX_MAIL_BODY_LEN) {
            $errors['BODY'] = tl(
                'mail_element_field_body_too_long');
        }
        /* harvest uploaded attachments. $_FILES['attachments']
           is the "array of files" shape PHP exposes when the
           file input is named "attachments[]". Each file is
           validated individually and any error short-circuits
           with a form-level message; a partial send (some
           attachments lost in the upload) would be worse than
           failing the whole compose attempt. */
        $attachments = [];
        $attached_files = $_FILES['attachments'] ?? null;
        if ($attached_files && is_array($attached_files['name'])) {
            $count = count($attached_files['name']);
            if ($count > self::MAIL_ATTACH_MAX_COUNT) {
                $errors['ATTACH'] = tl(
                    'mail_element_attachment_too_many',
                    self::MAIL_ATTACH_MAX_COUNT);
            }
            for ($i = 0; $i < $count && empty($errors['ATTACH']);
                $i++) {
                $err = $attached_files['error'][$i];
                if ($err === UPLOAD_ERR_NO_FILE) {
                    continue;
                }
                if ($err !== UPLOAD_ERR_OK) {
                    $errors['ATTACH'] = tl(
                        'mail_element_attachment_upload_failed',
                        $attached_files['name'][$i]);
                    break;
                }
                $size = (int) $attached_files['size'][$i];
                if ($size > self::MAIL_ATTACH_MAX_BYTES) {
                    $errors['ATTACH'] = tl(
                        'mail_element_attachment_too_large',
                        $attached_files['name'][$i],
                        intval(self::MAIL_ATTACH_MAX_BYTES /
                            (1024 * 1024)));
                    break;
                }
                $captured = '';
                set_error_handler(
                    function ($errno, $errstr) use (&$captured) {
                        if (strlen($captured) < 512) {
                            $captured .= $errstr;
                        }
                    });
                try {
                    $uploaded = ['tmp_name' =>
                        $attached_files['tmp_name'][$i]];
                    if (isset($attached_files['data'][$i])) {
                        $uploaded['data'] = $attached_files['data'][$i];
                    }
                    $content = $parent->model("mail")
                        ->uploadedFileContents($uploaded);
                } finally {
                    restore_error_handler();
                }
                if ($captured !== '' &&
                    C\nsdefined('LOG_DIR')) {
                    $line = "[" . date(DATE_RFC822) .
                        "] attach-read: " .
                        substr($attached_files['name'][$i],
                            0, 80) . " -> " .
                        substr(str_replace(["\r", "\n"], " ",
                            $captured), 0, 200) . "\n";
                    file_put_contents(C\LOG_DIR . "/mail.log",
                        $line, FILE_APPEND | LOCK_EX);
                }
                if ($content === false) {
                    $errors['ATTACH'] = tl(
                        'mail_element_attachment_upload_failed',
                        $attached_files['name'][$i]);
                    break;
                }
                $attachments[] = [
                    'filename' => $attached_files['name'][$i],
                    'mime_type' => $attached_files['type'][$i] ?:
                        'application/octet-stream',
                    'content' => $content,
                ];
            }
        }
        $reply_kind = $parent->clean(
            $_REQUEST['reply_kind'] ?? null, 'string', '');
        if (!in_array($reply_kind,
                ['reply', 'reply_all', 'forward'], true)) {
            $reply_kind = '';
        }
        $reply_uid = $parent->clean(
            $_REQUEST['reply_uid'] ?? null, 'int', 0);
        $reply_folder = $parent->clean(
            $_REQUEST['reply_folder'] ?? null, 'imap_arg', '');
        $reply_message_id = MailHeaderParser::cleanHeader(
            $_REQUEST['reply_message_id'] ?? '',
            self::MAIL_HEADER_MAX_LEN);
        $reply_references = MailHeaderParser::cleanHeader(
            $_REQUEST['reply_references'] ?? '',
            self::MAIL_HEADER_MAX_LEN);
        $extra_headers = [];
        if (($reply_kind === 'reply' || $reply_kind === 'reply_all')
                && $reply_message_id !== '') {
            $extra_headers['In-Reply-To'] = $reply_message_id;
            $extra_headers['References'] =
                $reply_references !== '' ? $reply_references :
                $reply_message_id;
        }
        if (empty($errors)) {
            $scheduled_at = (int) $parent->clean(
                $_REQUEST['scheduled_at'] ?? null, 'int', 0);
            if ($scheduled_at > 0) {
                if ($scheduled_at <= time()) {
                    $errors['SEND'] = tl(
                        'mail_element_compose_schedule_past');
                } else if (!empty($attachments)) {
                    $errors['SEND'] = tl(
                        'mail_element_compose_schedule_with_attachments');
                } else {
                    $scheduled_model = $parent->model(
                        "MailScheduled");
                    $composer = [
                        'subject' => $subject,
                        'to_list' => $raw_to,
                        'cc_list' => $raw_cc,
                        'bcc_list' => $raw_bcc,
                        'body_text' => $raw_body,
                        'body_html' => '',
                        'in_reply_to' =>
                            $extra_headers['In-Reply-To'] ?? '',
                        'references' =>
                            $extra_headers['References'] ?? ''];
                    $new_id = $scheduled_model->add(
                        $data["USER_ID"], $account_id,
                        $scheduled_at, $composer);
                    if ($new_id) {
                        $_REQUEST['arg'] = 'listMessages';
                        return $parent->redirectWithMessage(
                            tl(
                            'mail_element_compose_scheduled_flash',
                            date('Y-m-d H:i',
                            $scheduled_at)),
                            ['arg', 'account_id']);
                    }
                    $errors['SEND'] = tl(
                        'mail_element_message_send_failed',
                        'storage failed');
                }
            }
        }
        if (empty($errors)) {
            $recipients = ['to' => $to_addresses, 'cc' => $cc_addresses,
                'bcc' => $bcc_addresses];
            if ($account === null) {
                $backend = $this->userMailBackend(
                    self::MAILSITE_ACCOUNT_ID, $data);
                $primary_identity = $backend->senderEmail();
                $requested_identity = $this->parent->clean(
                    $_REQUEST["from_identity"] ?? "", "string", "");
                $from = $primary_identity;
                if ($requested_identity !== "") {
                    $available_identities =
                        $this->parent->model("mailAlias")->identitiesFor(
                        $data["USER_ID"], $primary_identity);
                    foreach ($available_identities as
                        $available_identity) {
                        if (strcasecmp($available_identity,
                            $requested_identity) === 0) {
                            $from = $available_identity;
                            break;
                        }
                    }
                }
                $backend->close();
                $result = $this->userMailDispatchMailsiteSend(
                    $data, $from, $recipients, $subject, $raw_body,
                    $attachments, $extra_headers);
            } else {
                $from = $this->userMailFormatFrom($account);
                $result = $this->userMailDispatchSend($account,
                    $from, $recipients, $subject, $raw_body,
                    $attachments, $extra_headers);
            }
            if ($result['ok']) {
                $reply_source_id = (int) $parent->clean(
                    $_REQUEST['reply_account_id'] ?? null, 'int',
                    $account_id);
                if (($reply_kind === 'reply' ||
                        $reply_kind === 'reply_all') &&
                        $reply_uid > 0 && $reply_folder !== '') {
                    /* mark the source message \Answered so other
                       mail clients show it as replied-to. Failures
                       here are non-fatal: send already succeeded
                       and the user has nothing more to do. Target
                       the SOURCE account: with the from-account
                       dropdown the user may have replied while
                       sending as a different identity. */
                    $answered_backend = null;
                    try {
                        $answered_backend =
                            $this->userMailBackend($reply_source_id, $data);
                        $answered_backend->setFlag($reply_folder,
                            $reply_uid, '\\Answered', true);
                    } catch (\Exception $answered_exception) {
                        $this->userMailArchiveLog(
                            "answered exception: " .
                            $answered_exception->getMessage());
                    } finally {
                        if ($answered_backend !== null) {
                            $answered_backend->close();
                        }
                    }
                }
                /* land back on this account's inbox with the
                   success flash. redirectWithMessage's second
                   argument is the list of fields to KEEP in the
                   redirect URL (added to the framework defaults);
                   we pull arg over to listMessages so we don't
                   redirect into another sendMessage (which would
                   re-fire the SMTP transmission and loop). */
                $_REQUEST['arg'] = 'listMessages';
                return $parent->redirectWithMessage(
                    tl('mail_element_message_sent'),
                    ['arg', 'account_id']);
            }
            $errors['SEND'] = tl(
                'mail_element_message_send_failed',
                $result['error']);
        }
        $display_name = '';
        foreach ($data['ACCOUNTS'] as $entry) {
            if ((int) $entry['ID'] === (int) $account_id) {
                $display_name = $entry['DISPLAY_NAME'] ?? '';
                break;
            }
        }
        $data["CONTENT_PANE"] = "compose";
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACCOUNT_DISPLAY_NAME"] = $display_name;
        if ($account === null) {
            $backend = $this->userMailBackend(self::MAILSITE_ACCOUNT_ID, $data);
            $data["FROM_DISPLAY"] = $parent->clean(
                $backend->senderEmail(), "string", "");
            $backend->close();
        } else {
            $data["FROM_DISPLAY"] = $parent->clean(
                $this->userMailFormatFrom($account), "string", "");
        }
        $data["FROM_OPTIONS"] = $this->userMailFromOptions($data);
        $requested_from = $parent->clean(
            $_REQUEST["from_option"] ?? "", "string", "");
        $data["FROM_VALUE"] = ($requested_from !== "") ?
            $requested_from : $account_id . '|' . $data["FROM_DISPLAY"];
        /* The compose form re-renders with whatever the user typed
           (TO/CC/BCC/SUBJECT/BODY) on a validation or send failure,
           so HTML-clean each here and let the view echo it raw. */
        $data["FORM_VALUES"] = [
            "TO" => $parent->clean(implode(", ", $to_addresses) ?:
                $raw_to, "string", ""),
            "CC" => $parent->clean(implode(", ", $cc_addresses) ?:
                $raw_cc, "string", ""),
            "BCC" => $parent->clean(implode(", ", $bcc_addresses) ?:
                $raw_bcc, "string", ""),
            "SUBJECT" => $parent->clean($subject, "string", ""),
            "BODY" => $parent->clean($raw_body, "string", ""),
        ];
        $reply_source_id = (int) $parent->clean(
            $_REQUEST['reply_account_id'] ?? null, 'int',
            $account_id);
        /* FOLDER / MESSAGE_ID / REFERENCES ride along in hidden
           inputs and originate from the replied-to message's
           headers, so clean them too; KIND is one of a fixed set
           and UID / SOURCE_ACCOUNT_ID are ints. */
        $data["REPLY_CONTEXT"] = [
            'KIND' => $reply_kind,
            'UID' => $reply_uid,
            'FOLDER' => $parent->clean($reply_folder, "string", ""),
            'MESSAGE_ID' => $parent->clean($reply_message_id,
                "string", ""),
            'REFERENCES' => $parent->clean($reply_references,
                "string", ""),
            'SOURCE_ACCOUNT_ID' => $reply_source_id,
        ];
        $data["FORM_ERRORS"] = $errors;
        return $data;
    }
    /**
     * userMailScheduledList renders the per-account Scheduled folder view: a
     * list of pending and failed scheduled-send rows for the currently active
     * account. Each row shows the target time, recipient list, subject, and
     * status badge; failed rows additionally surface the LAST_ERROR. A small
     * Cancel form per row POSTs to scheduledCancel for removal before the
     * dispatcher fires. Access-control fence: getAccount enforces user-
     * ownership of account_id; MailScheduledModel methods scope to user_id
     * regardless of account, providing belt + suspenders.
     * @return array|mixed $data for MailElement on success, or a
     *     redirectWithMessage when the account_id is invalid
     */
    protected function userMailScheduledList()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $account_model = $parent->model("MailAccount");
        $account = $account_model->getAccount($account_id,
            $data["USER_ID"]);
        if (!$account) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $scheduled_model = $parent->model("MailScheduled");
        $data["CONTENT_PANE"] = "scheduledList";
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
            $account["DISPLAY_NAME"], "string", "");
        $data["ACTIVE_FOLDER"] = "Scheduled";
        $data["SCHEDULED_MESSAGES"] =
            $scheduled_model->getMessagesForUser(
            $data["USER_ID"], $account_id);
        /* The scheduled-list view echoes TO_LIST, SUBJECT and
           LAST_ERROR. Truncate the long display fields on the raw
           value first (so a multibyte/entity boundary is never cut
           mid-sequence), then HTML-clean; the view echoes the
           prepared values raw and does no truncation of its own.
           STATUS is one of a fixed set used in a class name. */
        foreach ($data["SCHEDULED_MESSAGES"]
                as $scheduled_index => $scheduled_row) {
            $recipients = $scheduled_row['TO_LIST'] ?? '';
            if (strlen($recipients) > 80) {
                $recipients = substr($recipients, 0, 80) . "...";
            }
            $subject = $scheduled_row['SUBJECT'] ?? '';
            if (strlen($subject) > 60) {
                $subject = substr($subject, 0, 60) . "...";
            }
            $data["SCHEDULED_MESSAGES"][$scheduled_index]
                ["TO_LIST_DISPLAY"] = $parent->clean($recipients,
                "string", "");
            $data["SCHEDULED_MESSAGES"][$scheduled_index]
                ["SUBJECT_DISPLAY"] = $parent->clean($subject,
                "string", "");
            $data["SCHEDULED_MESSAGES"][$scheduled_index]
                ["LAST_ERROR_DISPLAY"] = $parent->clean(
                $scheduled_row['LAST_ERROR'] ?? '', "string", "");
        }
        return $data;
    }
    /**
     * userMailFormatFrom builds an the standard numbered 5322 From-header
     * value from an account
     * row. Returns just the bare email address, with no display-name wrapper:
     * the account's DISPLAY_NAME is for the UI's account list, not for the
     * sender identity in outgoing mail. Anyone who wants a real "Friendly Name
     * <email>" form can request it as a follow-up; until then the wire form is
     * simply "chris@pollett.org" rather than something like "Pollett
     * <chris@pollett.org>".
     * @param array $account row from MailAccountModel
     * @return string the sender email address
     */
    protected function userMailFormatFrom($account)
    {
        return MailAccountModel::senderEmail($account);
    }
    /**
     * userMailBuildReplyPrefill fetches the source message from IMAP and
     * assembles the prefill values + context needed to compose a reply, reply-
     * all, or forward. Returns null on any fetch/parse failure so the caller
     * can fall back to a blank compose. Prefill rules by kind: - reply: TO =
     * source Reply-To (else From). CC = empty. Subject prefixed "Re: " (not
     * double-prefixed). Body gets an attribution line followed by each line of
     * the source plain-text body prefixed with "> ". - reply_all: TO same as
     * reply. CC = source CC plus the remaining To recipients minus this
     * account's own address (the user is already the sender so they shouldn't
     * CC themselves). Subject and body same as reply. - forward: TO empty (user
     * fills it in). Subject prefixed "Fwd: ". Body has a "Begin forwarded
     * message" header block followed by the source body. No attribution.
     * Context returned to the renderer: - KIND: the validated kind string - UID
     * + FOLDER: for the post-send \Answered store call - MESSAGE_ID: source
     * Message-ID, used as In-Reply-To - REFERENCES: source References (or
     * Message-ID if none) plus the source Message-ID appended, used as the new
     * message's References header
     * @param int $account_id account whose backend to use to fetch the source
     *     message
     * @param array $data handler-shared data with USER_ID for the backend
     *     factory lookup
     * @param string $kind one of 'reply', 'reply_all', 'forward'
     * @param string $folder IMAP folder holding the source
     * @param int $uid IMAP UID of the source message
     * @return array|null associative array ['FORM_VALUES' => [TO, CC, SUBJECT,
     *     BODY], 'CONTEXT' => [KIND, UID, FOLDER, MESSAGE_ID, REFERENCES]], or
     *     null on fetch failure
     */
    protected function userMailBuildReplyPrefill($account_id, $data,
        $kind, $folder, $uid)
    {
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $raw = $backend->fetchMessage($folder, $uid);
            $my_email = strtolower($backend->senderEmail());
        } catch (\Exception $exception) {
            if ($backend !== null) {
                $backend->close();
            }
            return null;
        }
        $backend->close();
        $source = MimeMessage::parse($raw);
        return MailComposeBuilder::replyPrefill($source, $my_email,
            $kind, $folder, $uid);
    }
    /**
     * userMailDispatchSend constructs an SmtpClient for the given account and
     * asks it to transmit one message via sendImmediate. Returns a small status
     * array, holding whether it worked and what went wrong, so the caller
     * need not reach inside the client itself. SMTP_USERNAME and
     * SMTP_PASSWORD on the account row are assumed to have already been
     * resolved via MailAccountModel::getAccountForSending (which falls back to
     * IMAP credentials when SMTP-specific ones are blank).
     * @param array $account row including SMTP_HOST, SMTP_PORT, SMTP_USERNAME,
     *     SMTP_PASSWORD, SMTP_TLS_MODE
     * @param string $from From-header value
     * @param string $to RCPT TO email address (bare)
     * @param string $subject Subject header
     * @param string $body message body
     * @param array $attachments optional list of attachment associative arrays
     *     (filename, mime_type, content) forwarded to
     *     SmtpClient::sendImmediate; empty list produces a single-part
     *     text/plain message
     * @param array $extra_headers optional name=>value map of additional the
     *     standard
     *     5322 headers (In-Reply-To, References for the Reply flow) emitted
     *     alongside the standard headers in the sent message
     * @return array ['ok' => bool, 'error' => string]
     */
    protected function userMailDispatchSend($account, $from, $to,
        $subject, $body, $attachments = [], $extra_headers = [])
    {
        $sender_email = MailAccountModel::senderEmail($account);
        $client = new SmtpClient(
            $sender_email,
            $account['SMTP_HOST'] ?? '',
            (int) ($account['SMTP_PORT'] ?? 587),
            $account['SMTP_USERNAME'] ?? '',
            $account['SMTP_PASSWORD'] ?? '',
            $account['SMTP_TLS_MODE'] ?? 'starttls',
            !empty($account['ALLOW_SELF_SIGNED']));
        $ok = $client->sendImmediate($subject, $from, $to, $body,
            $attachments, $extra_headers);
        if ($ok) {
            /* archive a copy of just-sent mail into the IMAP
               Sent folder; failures here are non-fatal since
               the SMTP send already succeeded -- the recipient
               got the mail. */
            $this->userMailArchiveToSent($account,
                $client->last_wire_message);
        }
        return ['ok' => (bool) $ok,
            'error' => $ok ? '' : trim($client->getLastError())];
    }
    /**
     * userMailDispatchMailsiteSend sends an outbound message on behalf of a
     * MailSite-account user. Builds the the standard numbered 5322 wire bytes
     * once and delivers
     * them along two channels: local recipients (whose domain is in
     * MAIL_DOMAINS and who have a MailSite account on this install) get the
     * message appended directly to their INBOX via MailSite::deliverMail,
     * bypassing the SMTP listener; external recipients go through Yioop's
     * notification SMTP relay (MAIL_SERVER and so on). After delivery the same
     * bytes
     * are archived to the sender's Sent folder via the MailSite backend's
     * appendMessage. Refuses the send -- rather than partially delivering -- if
     * any recipient is external and the install has no outbound SMTP server
     * configured. Partial success would leave the sender thinking everyone got
     * the message when some recipients silently did not.
     * @param array $data userMailBaseData (carries USER_NAME and so on needed
     *     for
     *     the synthetic-account backend factory)
     * @param string $from envelope MAIL FROM and From-header bare address
     *     (already computed via the backend's senderEmail in the caller)
     * @param array $recipients ['to' => [], 'cc' => [], 'bcc' => []] lists of
     *     parsed address strings
     * @param string $subject Subject header value
     * @param string $body plain-text body
     * @param array $attachments parsed attachment list (each entry: filename,
     *     mime_type, content)
     * @param array $extra_headers In-Reply-To / References for reply mode
     * @return array ['ok' => bool, 'error' => string]
     */
    protected function userMailDispatchMailsiteSend($data, $from,
        $recipients, $subject, $body, $attachments = [],
        $extra_headers = [])
    {
        $to_addresses = $recipients['to'] ?? [];
        $cc_addresses = $recipients['cc'] ?? [];
        $bcc_addresses = $recipients['bcc'] ?? [];
        $to_header = implode(", ", $to_addresses);
        $cc_header = implode(", ", $cc_addresses);
        $message_arg = ($cc_header === '') ? $to_header :
            ['to' => $to_header, 'cc' => $cc_header];
        $bytes = ML\MimeMessage::build($from, $message_arg, $subject,
            $body, $attachments, $extra_headers);
        /* classify recipients: domains in MAIL_DOMAINS go to
           direct FileMailStorage delivery; everything else to
           the outbound SMTP relay. The Bcc list is split by
           the same rule but its addresses never appear in the
           wire headers (the bytes are built with To and Cc
           only). */
        $local_domains = MailSiteFactory::localDomains();
        $local_domain_lookup = [];
        foreach ($local_domains as $domain) {
            $local_domain_lookup[strtolower($domain)] = true;
        }
        $local_rcpts = [];
        $external_rcpts = [];
        foreach (array_merge($to_addresses, $cc_addresses, $bcc_addresses) as
                $address) {
            $bare = MailHeaderParser::extractBareAddress($address);
            if ($bare === '') {
                continue;
            }
            $at_pos = strrpos($bare, '@');
            $domain = ($at_pos === false) ? '' :
                strtolower(substr($bare, $at_pos + 1));
            if ($domain !== '' && isset($local_domain_lookup[$domain])) {
                $local_rcpts[] = $bare;
            } else {
                $external_rcpts[] = $bare;
            }
        }
        /* local delivery via MailSite::deliverMail. one round-
           trip per recipient, no SMTP listener loopback. failures
           accumulate so we can report a partial outcome rather
           than dropping silently. */
        $local_failures = [];
        if (!empty($local_rcpts)) {
            $site = MailSiteFactory::build();
            foreach ($local_rcpts as $rcpt) {
                $delivered = $site->deliverMail($from, $rcpt,
                    $bytes, ['source' => 'webmail-compose']);
                if ($delivered === false) {
                    $local_failures[] = $rcpt;
                }
            }
        }
        /* external delivery as a peer MTA: group recipients by
           lowercase domain, resolve MX records per domain, and
           try each MX in priority order. One DATA carries all
           the domain's recipients at once (RFC 5321 §3.6: a
           single transaction may serve multiple RCPT TOs as long
           as they share an SMTP server). No AUTH -- we're the
           sending MTA talking to the recipient's MX, not a
           client submitting through a smarthost. Empty login
           and password on the SmtpClient instance skip the
           AUTH LOGIN exchange in startSession. Per MX host, port
           25 is tried first; when C\p('MAIL_TEST_MODE') is on and
           C\p('MAIL_TEST_MODE_FALLBACK_PORT') is nonzero, that port
           is tried as a TCP-failure fallback so a deployment
           whose network egress blocks 25 can reach the upstream
           through a tunnel forwarder on a higher port (the
           smtp_tunnel.php helper script is the intended
           use-case). Replaces an earlier unconditional 25 -> 587
           fallback that was speculative -- the test server did
           not in fact accept unauthenticated submission on 587.
           'opportunistic' STARTTLS upgrades the channel when the
           peer advertises it and passes plaintext to peers that
           don't. */
        $external_failures = [];
        if (!empty($external_rcpts)) {
            $by_domain = [];
            foreach ($external_rcpts as $rcpt) {
                $at_pos = strrpos($rcpt, '@');
                if ($at_pos === false) {
                    $external_failures[] =
                        $rcpt . ': no domain';
                    continue;
                }
                $domain = strtolower(substr($rcpt, $at_pos + 1));
                $by_domain[$domain][] = $rcpt;
            }
            $ports_to_try = [25];
            if (C\p('MAIL_TEST_MODE') &&
                    (int) C\p('MAIL_TEST_MODE_FALLBACK_PORT') > 0) {
                $ports_to_try[] = (int) C\p('MAIL_TEST_MODE_FALLBACK_PORT');
            }
            foreach ($by_domain as $domain => $domain_rcpts) {
                $mx_hosts = SmtpClient::resolveMxHosts($domain);
                if (empty($mx_hosts)) {
                    $external_failures[] =
                        implode(', ', $domain_rcpts) .
                        ': no MX record for ' . $domain;
                    continue;
                }
                $accepted = false;
                $last_error = '';
                foreach ($mx_hosts as $mx_host) {
                    foreach ($ports_to_try as $port) {
                        $smtp = new SmtpClient($from, $mx_host,
                            $port, '', '', 'opportunistic',
                            false);
                        if ($smtp->deliverBytes($from,
                                $domain_rcpts, $bytes)) {
                            $accepted = true;
                            break 2;
                        }
                        $last_error = trim(
                            $smtp->getLastError());
                        /* fall through to the submission port
                           only when the failure was at the TCP
                           layer (refused / timed out). Server-
                           level SMTP rejections on port 25 are
                           definitive for that MX -- the server
                           is reachable and said no. */
                        if (stripos($last_error,
                                'could not connect') === false) {
                            break;
                        }
                    }
                }
                if (!$accepted) {
                    $external_failures[] =
                        implode(', ', $domain_rcpts) .
                        ': ' . ($last_error !== '' ?
                        $last_error : 'no MX accepted');
                }
            }
        }
        /* archive a copy in the sender's Sent folder. Same bytes
           that went to local recipients (and that the relay
           rebuilt from for external). Failures here are non-
           fatal: delivery already happened and the user has
           nothing more to do; the archive miss is logged. */
        $archive_backend = null;
        try {
            $archive_backend = $this->userMailBackend(
                self::MAILSITE_ACCOUNT_ID, $data);
            $sent_folder = null;
            foreach ($archive_backend->listFolders() as $folder) {
                if (strcasecmp($folder['SPECIAL_USE'] ?? '',
                        '\\Sent') === 0 ||
                        strcasecmp($folder['NAME'] ?? '',
                        'Sent') === 0) {
                    $sent_folder = $folder['NAME'];
                    break;
                }
            }
            if ($sent_folder !== null) {
                $archive_backend->appendMessage($sent_folder,
                    $bytes, ['\\Seen']);
            } else {
                $this->userMailArchiveLog(
                    "mailsite-archive: no Sent folder");
            }
        } catch (\Exception $exception) {
            $this->userMailArchiveLog(
                "mailsite-archive exception: " .
                $exception->getMessage());
        } finally {
            if ($archive_backend !== null) {
                $archive_backend->close();
            }
        }
        $failure_parts = [];
        if (!empty($external_failures)) {
            $failure_parts[] = tl(
                'mail_element_external_delivery_failed',
                implode('; ', $external_failures));
        }
        if (!empty($local_failures)) {
            $failure_parts[] = tl(
                'mail_element_local_delivery_failed',
                implode(', ', $local_failures));
        }
        if (!empty($failure_parts)) {
            return ['ok' => false,
                'error' => implode(' ', $failure_parts)];
        }
        return ['ok' => true, 'error' => ''];
    }
    /**
     * userMailArchiveToSent connects to the account's IMAP server, finds the
     * Sent folder, and APPENDs the given the standard numbered 822 message
     * there with the \Seen
     * flag. Failures (folder not discoverable, APPEND rejected, IMAP connect
     * refused, and so on) are logged to the SMTP mail.log but do not raise an
     * exception or change the overall send-status seen by the user. The cost of
     * doing this work synchronously after every send is small (a few hundred
     * milliseconds for typical messages), and doing it asynchronously would
     * require a queue mechanism the user-mail feature does not yet have.
     * @param array $account row from getAccountForSending
     * @param string $rfc822 the message bytes that went out via SMTP, with CRLF
     *     line endings and no SMTP terminator
     */
    protected function userMailArchiveToSent($account, $rfc822)
    {
        if ($rfc822 === '') {
            $this->userMailArchiveLog(
                "skipped: empty wire message");
            return;
        }
        $account_id = (int) ($account['ID'] ?? 0);
        if ($account_id <= 0) {
            $this->userMailArchiveLog(
                "skipped: missing account ID");
            return;
        }
        $data = $this->userMailBaseData();
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $folders = $backend->listFolders();
            $sent_folder = null;
            foreach ($folders as $folder) {
                if (strcasecmp($folder['SPECIAL_USE'] ?? '',
                        '\\Sent') === 0) {
                    $sent_folder = $folder['NAME'];
                    break;
                }
            }
            if ($sent_folder === null) {
                foreach (['Sent', 'Sent Items',
                        '[Gmail]/Sent Mail'] as $candidate) {
                    foreach ($folders as $folder) {
                        if (strcasecmp($folder['NAME'],
                                $candidate) === 0) {
                            $sent_folder = $folder['NAME'];
                            break 2;
                        }
                    }
                }
            }
            if ($sent_folder === null) {
                $this->userMailArchiveLog(
                    "no Sent folder discovered");
                return;
            }
            $backend->appendMessage($sent_folder, $rfc822,
                ['\\Seen']);
            $this->userMailArchiveLog(
                "APPEND to $sent_folder OK");
        } catch (ML\MailBackendException $exception) {
            $this->userMailArchiveLog(
                "APPEND failed: " . $exception->getMessage());
        } catch (\Exception $exception) {
            $this->userMailArchiveLog(
                "exception: " . $exception->getMessage());
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
    }
    /**
     * userMailBulkAction handles a bulk action (delete or move) submitted from
     * the inbox view. Expected request parameters: account_id (int), folder
     * (imap_arg, the source folder the user is acting on), bulk_action
     * ('delete' or 'move'), uids (array of int), destination (imap_arg, only
     * required when bulk_action is 'move'). Validates everything, dispatches to
     * the backend's bulk methods (setFlagBulk / moveMessagesBulk /
     * deleteMessagesBulk), and redirects back to the source folder with a flash
     * message describing the result. Caps the UID list at MAIL_BULK_MAX_COUNT
     * to prevent pathological request sizes (a UI bug or replay attempt sending
     * tens of thousands of UIDs); the value is high enough that any sane user-
     * selected batch fits.
     * @return mixed result of redirectWithMessage
     */
    protected function userMailBulkAction()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $source = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $action = $parent->clean(
            $_REQUEST['bulk_action'] ?? null, 'string', '');
        $destination = $parent->clean(
            $_REQUEST['destination'] ?? null, 'imap_arg', '');
        $destination_account_id = $parent->clean(
            $_REQUEST['destination_account_id'] ?? null, 'int',
            $account_id);
        $raw_uids = $_REQUEST['uids'] ?? [];
        if (!is_array($raw_uids)) {
            $raw_uids = [];
        }
        $uids = [];
        foreach ($raw_uids as $raw_uid) {
            $uid = (int) $raw_uid;
            if ($uid > 0) {
                $uids[] = $uid;
            }
            if (count($uids) >= self::MAIL_BULK_MAX_COUNT) {
                break;
            }
        }
        if (empty($uids) || !in_array($action,
            ['delete', 'move', 'mark_read', 'mark_unread'],
            true)) {
            return $parent->redirectWithMessage(
                tl('mail_element_bulk_invalid'),
                ['arg', 'account_id', 'folder']);
        }
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage());
        }
        if ($source === '') {
            $source = $backend->defaultFolder();
        }
        /* validate move destination against the cached folder
           list of the destination account so a forged form post
           cannot copy mail to an attacker-named folder; this also
           stops the user from moving into a no-select / namespace
           placeholder. The destination account defaults to the
           source account, in which case this is an ordinary
           same-account move; a different account id makes it a
           cross-account move (copy into the other account, then
           purge from the source). */
        $cross_account =
            ($destination_account_id !== $account_id);
        if ($action === 'move') {
            $mail_model = $parent->model("Mail");
            $destination_folders =
                $mail_model->foldersFor($destination_account_id);
            /* the session folder cache is only warm for accounts
               opened this session; when moving into an account the
               user has not visited (commonly the local MailSite
               account), fetch its folder list so a legitimate
               destination is not rejected. */
            if (empty($destination_folders)) {
                try {
                    $destination_backend =
                        $this->userMailBackend($destination_account_id, $data);
                    $destination_folders =
                        $destination_backend->listFolders();
                    $destination_backend->close();
                    $mail_model->cacheFolders(
                        $destination_account_id,
                        $destination_folders);
                } catch (ML\MailBackendException $exception) {
                    $destination_folders = [];
                }
            }
            $valid_destination = false;
            foreach ($destination_folders as $folder_row) {
                if ($folder_row['NAME'] === $destination) {
                    if (!empty($folder_row['SELECTABLE'])) {
                        $valid_destination = true;
                    }
                    break;
                }
            }
            $same_place = (!$cross_account &&
                $destination === $source);
            if (!$valid_destination || $same_place) {
                $backend->close();
                return $parent->redirectWithMessage(
                    tl('mail_element_bulk_invalid_destination'),
                    ['arg', 'account_id', 'folder']);
            }
        }
        $this->userMailLog('bulk-apply', ['action' => $action,
            'source' => $source, 'dest' => $destination,
            'dest_account' => $destination_account_id,
            'count' => count($uids)]);
        $moved_count = count($uids);
        try {
            if ($action === 'mark_read') {
                $backend->setFlagBulk($source, $uids, '\\Seen',
                    true);
            } else if ($action === 'mark_unread') {
                $backend->setFlagBulk($source, $uids, '\\Seen',
                    false);
            } else if ($action === 'move' && $cross_account) {
                $moved_count = $this->mailMoveAcrossAccounts(
                    $backend, $source, $uids,
                    $destination_account_id, $destination,
                    $data);
            } else if ($action === 'move') {
                $backend->moveMessagesBulk($source, $uids,
                    $destination);
            } else {
                $backend->deleteMessagesBulk($source, $uids);
            }
        } catch (ML\MailBackendException $exception) {
            $backend->close();
            return $parent->redirectWithMessage(
                $exception->getMessage(),
                ['arg', 'account_id', 'folder']);
        }
        $backend->close();
        $_REQUEST['arg'] = 'listMessages';
        $_REQUEST['folder'] = $source;
        $num_uids = $moved_count;
        if ($action === 'delete') {
            $flash = ($num_uids === 1) ?
                tl('mail_element_bulk_deleted_one') :
                tl('mail_element_bulk_deleted_n', $num_uids);
        } else if ($action === 'mark_read') {
            $flash = ($num_uids === 1) ?
                tl('mail_element_bulk_marked_read_one') :
                tl('mail_element_bulk_marked_read_n', $num_uids);
        } else if ($action === 'mark_unread') {
            $flash = ($num_uids === 1) ?
                tl('mail_element_bulk_marked_unread_one') :
                tl('mail_element_bulk_marked_unread_n',
                $num_uids);
        } else if ($cross_account) {
            $destination_label = $destination_account_id;
            foreach ($data['ACCOUNTS'] ?? [] as $account_row) {
                if ((int) ($account_row['ID'] ?? -1) ===
                    $destination_account_id) {
                    $destination_label =
                        $account_row['DISPLAY_NAME'] ??
                        $destination_account_id;
                    break;
                }
            }
            $flash = ($num_uids === 1) ?
                tl('mail_element_bulk_moved_account_one',
                    $destination, $destination_label) :
                tl('mail_element_bulk_moved_account_n', $num_uids,
                    $destination, $destination_label);
        } else {
            $flash = ($num_uids === 1) ?
                tl('mail_element_bulk_moved_one', $destination) :
                tl('mail_element_bulk_moved_n', $num_uids,
                    $destination);
        }
        ML\MailUnreadProbe::invalidate($data["USER_ID"]);
        unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
        if ($cross_account) {
            unset($_SESSION["MAIL_FOLDER_UNREAD"]
                [$destination_account_id]);
        }
        return $parent->redirectWithMessage($flash,
            ['arg', 'account_id', 'folder']);
    }
    /**
     * mailMoveAcrossAccounts moves a batch of messages from the currently open
     * account to a folder in a different account. Each message is fetched as
     * raw the standard numbered 5322 bytes from the source and appended to the
     * destination
     * account's folder; only the messages that append successfully are then
     * permanently purged from the source, so a transport failure never destroys
     * mail (the worst case is a message left in both places rather than lost).
     * The destination backend is opened here and always closed before
     * returning.
     * @param object $source_backend already-open MailBackend for the account
     *     the messages currently live in
     * @param string $source source folder name
     * @param array $uids list of source IMAP UIDs to move
     * @param int $destination_account_id account id to move into
     * @param string $destination destination folder name in that account
     * @param array $data base mail data used to build the destination backend
     * @return int number of messages actually moved (appended to the
     *     destination and purged from the source)
     * @throws ML\MailBackendException if the destination account cannot be
     *     opened
     */
    protected function mailMoveAcrossAccounts($source_backend,
        $source, $uids, $destination_account_id, $destination,
        $data)
    {
        $destination_backend = $this->userMailBackend(
            $destination_account_id, $data);
        $copied_uids = [];
        try {
            foreach ($uids as $uid) {
                $bytes = $source_backend->fetchMessage($source,
                    $uid);
                if ($bytes === '' || $bytes === null) {
                    continue;
                }
                $destination_backend->appendMessage($destination,
                    $bytes);
                $copied_uids[] = $uid;
            }
        } finally {
            $destination_backend->close();
        }
        if (!empty($copied_uids)) {
            $source_backend->purgeMessagesBulk($source,
                $copied_uids);
        }
        return count($copied_uids);
    }
    /**
     * userMailToggleFlag toggles the \Flagged IMAP flag on a single message via
     * UID STORE. Backs the starred-column click affordance in the inbox view.
     * Expected request parameters: account_id (int), folder (string, the source
     * folder), uid (int, the message UID), flag (int 0 or 1, the desired final
     * state). Idempotent: the caller passes the desired final state, not
     * "toggle from current," so two browser tabs both clicking the same star do
     * not ping-pong. The "flip" UI semantic is resolved in the template that
     * renders the link target with the inverse of the current state. Redirects
     * back to the inbox listing on success or failure with a flash message; on
     * success the inbox re-renders with the new IS_FLAGGED state already
     * reflected by the envelope parser.
     * @return mixed redirectWithMessage result
     */
    protected function userMailToggleFlag()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $uid = $parent->clean(
            $_REQUEST['uid'] ?? null, 'int', 0);
        $flag = (bool) $parent->clean(
            $_REQUEST['flag'] ?? null, 'bool', false);
        if ($folder === '' || $uid <= 0) {
            return $parent->redirectWithMessage(
                tl('mail_element_toggle_flag_invalid'),
                ['arg', 'account_id', 'folder']);
        }
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $this->userMailLog('toggle-flag',
                ['folder' => $folder, 'uid' => $uid,
                'flag' => '\\Flagged',
                'value' => $flag ? '1' : '0']);
            $backend->setFlag($folder, $uid, '\\Flagged', $flag);
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage(),
                ['arg', 'account_id', 'folder', 'sort_key',
                'sort_reverse', 'filter', 'unread_only',
                'flagged_only']);
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
        $_REQUEST['arg'] = 'listMessages';
        $_REQUEST['folder'] = $folder;
        return $parent->redirectWithMessage('',
            ['arg', 'account_id', 'folder', 'sort_key',
            'sort_reverse', 'filter', 'unread_only',
            'flagged_only']);
    }
    /**
     * userMailFolderAction handles a folder-management action (create, rename,
     * delete) submitted from the side panel. Expected request parameters:
     * account_id (int), folder_action ('create'|'rename'|'delete'), folder
     * (existing name, required for rename/delete), new_name (the new name,
     * required for create/rename). Validates that the source folder (for
     * rename/delete) is in the cached folder list, selectable, and not special-
     * use or INBOX. After a successful operation re-fetches the folder list so
     * the UI redraws with the change. Redirects back to the previous page with
     * a flash message.
     * @return mixed result of redirectWithMessage
     */
    protected function userMailFolderAction()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $op = $parent->clean(
            $_REQUEST['folder_action'] ?? null, 'string', '');
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $new_name = $parent->clean(
            $_REQUEST['new_name'] ?? null, 'imap_arg', '');
        if (!in_array($op, ['create', 'rename', 'delete'], true)) {
            return $parent->redirectWithMessage(
                tl('mail_element_folder_invalid'),
                ['arg', 'account_id']);
        }
        /* common validation: rename and delete need a source
           folder we can find in the cached list, selectable, and
           not protected (INBOX or special-use). */
        if ($op === 'rename' || $op === 'delete') {
            $check = $this->userMailFolderProtected($account_id,
                $folder);
            if ($check !== '') {
                return $parent->redirectWithMessage($check,
                    ['arg', 'account_id']);
            }
        }
        if ($op === 'create' || $op === 'rename') {
            $valid = '';
            if ($new_name === '') {
                $valid = tl('mail_element_folder_name_required');
            } else if (strlen($new_name) >
                self::MAIL_FOLDER_NAME_MAX_LEN) {
                $valid = tl('mail_element_folder_name_too_long',
                    self::MAIL_FOLDER_NAME_MAX_LEN);
            } else if (preg_match('/[\x00-\x1F\x7F"%*]/',
                $new_name)) {
                $valid = tl('mail_element_folder_name_invalid');
            } else if (strcasecmp($new_name, 'INBOX') === 0) {
                $valid = tl('mail_element_folder_protected');
            }
            if ($valid !== '') {
                return $parent->redirectWithMessage($valid,
                    ['arg', 'account_id']);
            }
        }
        $this->userMailLog('folder-op', ['op' => $op,
            'folder' => $folder, 'new_name' => $new_name]);
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            if ($op === 'create') {
                $backend->createFolder($new_name);
                $this->userMailArchiveLog(
                    "CREATE $new_name OK");
            } else if ($op === 'rename') {
                $backend->renameFolder($folder, $new_name);
                $this->userMailArchiveLog(
                    "RENAME $folder -> $new_name OK");
            } else {
                $backend->deleteFolder($folder);
                $this->userMailArchiveLog(
                    "DELETE $folder OK");
            }
        } catch (ML\MailBackendException $exception) {
            $detail = ($op === 'create') ? "CREATE $new_name" :
                (($op === 'rename') ?
                "RENAME $folder -> $new_name" :
                "DELETE $folder");
            $this->userMailArchiveLog(
                "$detail FAILED: " . $exception->getMessage());
            $this->userMailLog('folder-op-fail', ['op' => $op,
                'folder' => $folder, 'new_name' => $new_name,
                'error' => $exception->getMessage()]);
            return $parent->redirectWithMessage(
                $exception->getMessage(),
                ['arg', 'account_id']);
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
        $_REQUEST['arg'] = 'listMessages';
        $this->userMailLog('folder-op-ok', ['op' => $op,
            'folder' => $folder, 'new_name' => $new_name]);
        /* refresh the cached folder list so the side panel
           re-renders with the change on the next page load. */
        $parent->model("Mail")->invalidateFolders($account_id);
        unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
        /* rename/delete change folder identities, so drop the
           per-account expanded-folder map; otherwise stale
           entries linger for paths that no longer exist or have
           been renamed. create doesn't invalidate existing
           entries. */
        if ($op === 'rename' || $op === 'delete') {
            unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]);
        }
        if ($op === 'create') {
            $flash = tl('mail_element_folder_create_ok', $new_name);
        } else if ($op === 'rename') {
            $flash = tl('mail_element_folder_rename_ok', $new_name);
        } else {
            $flash = tl('mail_element_folder_delete_ok', $folder);
        }
        return $parent->redirectWithMessage($flash,
            ['arg', 'account_id']);
    }
    /**
     * userMailFolderProtected returns an empty string when the folder is safe
     * to rename or delete; otherwise returns a localized error message
     * explaining the refusal. A folder is protected when it (a) is not in the
     * cached folder list (we only act on known folders to prevent name-
     * injection from forged form posts), (b) is not selectable (a namespace
     * placeholder), (c) carries a the standard numbered 6154 special-use
     * attribute (\Sent,
     * \Trash, \Drafts, \Junk, \Archive, \All, \Flagged), or (d) is named INBOX
     * (special-cased by the standard numbered 3501 §5.1 even without a
     * special-use attribute).
     * @param int $account_id the account whose cached folder list we look up
     * @param string $folder the folder name being acted on
     * @return string empty on safe; otherwise a localized error message
     *     explaining the refusal
     */
    protected function userMailFolderProtected($account_id,
        $folder)
    {
        if ($folder === '' || strcasecmp($folder, 'INBOX') === 0) {
            return tl('mail_element_folder_protected');
        }
        $folders = $this->parent->model("Mail")
            ->foldersFor($account_id);
        foreach ($folders as $folder_row) {
            if ($folder_row['NAME'] === $folder) {
                if (empty($folder_row['SELECTABLE'])) {
                    return tl('mail_element_folder_protected');
                }
                if (!empty($folder_row['SPECIAL_USE'])) {
                    return tl('mail_element_folder_protected');
                }
                return '';
            }
        }
        return tl('mail_element_folder_invalid');
    }
    /**
     * userMailAccountRename handles an inline-rename of a mail account's
     * display name, submitted from the side-panel long-press UI. Expected
     * request parameters: account_id (int), new_name (string). Validates that
     * the user owns the account, that the new name is non-empty, under
     * MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN characters, and contains no control
     * characters. Updates only the DISPLAY_NAME column via the model method of
     * the same purpose, then redirects back to the mail entry point so the side
     * panel re-renders with the new label.
     * @return mixed result of redirectWithMessage
     */
    protected function userMailAccountRename()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $new_name = $parent->clean(
            $_REQUEST['new_name'] ?? null, 'string', '');
        $new_name = trim($new_name);
        if ($new_name === '') {
            return $parent->redirectWithMessage(
                tl('mail_element_account_rename_empty'));
        }
        if (strlen($new_name) >
            self::MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN) {
            return $parent->redirectWithMessage(tl(
                'mail_element_account_rename_too_long',
                self::MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN));
        }
        if (preg_match('/[\x00-\x1F\x7F]/', $new_name)) {
            return $parent->redirectWithMessage(
                tl('mail_element_account_rename_invalid'));
        }
        $account_model = $parent->model("MailAccount");
        $existing = $account_model->getAccount($account_id,
            $data["USER_ID"]);
        if (!$existing) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $account_model->updateDisplayName($account_id,
            $data["USER_ID"], $new_name);
        return $parent->redirectWithMessage(
            tl('mail_element_account_rename_ok', $new_name));
    }
    /**
     * userMailLog generalized one-line log writer used by the IMAP/SMTP
     * instrumentation across this component. Each call emits one record of the
     * form [<rfc822-date>] <tag>: k1=v1 k2=v2 ... so an operator tailing
     * mail.log gets a greppable trail. Values are sanitized: newlines and
     * control characters get squashed to single spaces and the result is
     * truncated to MAIL_LOG_FIELD_MAX_LEN. Keys are emitted as-is and assumed
     * to be alphanumeric. Gates and rotation happen inside
     * SmtpClient::appendLog so disabled logging is a silent no-op and a long-
     * running install doesn't grow mail.log without bound.
     * @param string $tag short identifier for the kind of event (e.g. "imap-
     *     open", "imap-select", "folder-create"); shows up after the timestamp
     *     in the log line
     * @param array $fields associative array of key => value fields to include
     *     after the tag; values are stringified and sanitized
     * @internal Used by ImapMailBackend during the in-progress backend
     *     migration; can revert to protected (or move into ImapMailBackend)
     *     once the patch 12 cleanup folds the IMAP-protocol helpers into the
     *     backend
     */
    public function userMailLog($tag, $fields = [])
    {
        $parts = [];
        foreach ($fields as $key => $value) {
            $clean = str_replace(["\r", "\n", "\t"], " ",
                (string) $value);
            if (strlen($clean) > self::MAIL_LOG_FIELD_MAX_LEN) {
                $clean = substr($clean, 0,
                    self::MAIL_LOG_FIELD_MAX_LEN) . "…";
            }
            $parts[] = $key . "=" . $clean;
        }
        $line = "[" . date(DATE_RFC822) . "] " . $tag;
        if (!empty($parts)) {
            $line .= ": " . implode(" ", $parts);
        }
        $line .= "\n";
        SmtpClient::appendLog($line);
    }
    /**
     * userMailArchiveLog appends a one-line note about the IMAP archive step to
     * LOG_DIR/mail.log. Lives alongside the SmtpClient transcript lines so an
     * admin tailing the log file sees the whole send-then-archive sequence in
     * one place.
     * @param string $message free-form description of what happened during the
     *     archive step
     */
    protected function userMailArchiveLog($message)
    {
        $this->userMailLog('sent-archive', ['msg' => $message]);
    }
    /**
     * userMailViewMessage fetches a single message body and prepares $data for
     * the single-message view. Falls back to the inbox view on any IMAP error.
     * @return mixed $data for MailElement, or redirectWithMessage on connect
     *     failure
     */
    protected function userMailViewMessage()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
        if ($uid <= 0) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage());
        }
        $data["CONTENT_PANE"] = "message";
        $data["ACCOUNT_ID"] = $account_id;
        $data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
            $backend->displayName(), "string", "");
        $data["UID"] = $uid;
        $data["MESSAGE_BODY"] = "";
        $requested_folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        $default_folder = $backend->defaultFolder();
        $folder = $requested_folder !== "" ? $requested_folder :
            $default_folder;
        $data["ACTIVE_FOLDER"] = $folder;
        /* The message-view back link echoes the active folder
           name; ACTIVE_FOLDER itself stays raw for URLs/IMAP, so
           provide a cleaned copy for display. */
        $data["ACTIVE_FOLDER_HTML"] = $parent->clean($folder,
            "string", "");
        /* the message-view template's move-dropdown needs the
           folder list; populate from the session cache when
           present, else fetch fresh. handles the deep-link
           case where the user arrives at a viewMessage URL
           without first having rendered the inbox. */
        $mail_model = $parent->model("Mail");
        $cached_folders = $mail_model->cachedFolders($account_id);
        if ($cached_folders !== null) {
            $data["FOLDERS"] = $cached_folders;
        }
        try {
            if (empty($data["FOLDERS"])) {
                $data["FOLDERS"] = $backend->listFolders();
                $backend->annotateUnreadCounts($data["FOLDERS"]);
                $mail_model->cacheFolders($account_id,
                    $data["FOLDERS"]);
            }
            $this->userMailDecorateFolderDisplay($data);
            $bytes = $backend->fetchMessage($folder, $uid);
            $data["MESSAGE_BODY"] = $bytes;
            $data["MIME_MESSAGE"] = MimeMessage::parse($bytes);
            $data["MESSAGE_VIEW"] = $this->userMailBuildMessageView(
                $data["MIME_MESSAGE"], $bytes);
            /* Verify the message's DKIM signature for the security
               badge. verify() returns a status (pass/fail/no_key/
               none) plus the signing domain and selector; a 'none'
               result means no signature and no badge is shown. The
               domain and selector are cleaned for display since they
               come from the received message. The lookup does a DNS
               query for the signer's key, so it runs once here on
               view rather than on every render of the pane. */
            $verdict = ML\DkimKey::verify($bytes);
            if (($verdict['status'] ?? ML\DkimKey::VERIFY_NONE) !==
                ML\DkimKey::VERIFY_NONE) {
                $data["DKIM_STATUS"] = $verdict['status'];
                $data["DKIM_DOMAIN"] = $parent->clean(
                    $verdict['domain'] ?? '', "string", "");
                $data["DKIM_SELECTOR"] = $parent->clean(
                    $verdict['selector'] ?? '', "string", "");
                $data["DKIM_DNS_NAME"] = $parent->clean(
                    $verdict['dns_name'] ?? '', "string", "");
                $data["DKIM_KEY_FOUND"] =
                    !empty($verdict['key_found']);
                $data["DKIM_ALGORITHM"] = $parent->clean(
                    $verdict['algorithm'] ?? '', "string", "");
                $data["DKIM_SIGNED_HEADERS"] = $parent->clean(
                    $verdict['signed_headers'] ?? '', "string", "");
                $data["DKIM_BODY_HASH_HEADER"] = $parent->clean(
                    $verdict['body_hash_header'] ?? '', "string", "");
                $data["DKIM_BODY_HASH_COMPUTED"] = $parent->clean(
                    $verdict['body_hash_computed'] ?? '', "string",
                    "");
                $data["DKIM_BODY_HASH_MATCH"] =
                    !empty($verdict['body_hash_match']);
                $data["DKIM_SIGNATURE_OK"] =
                    !empty($verdict['signature_ok']);
            }
            /* mark as read on view, mirroring the implicit
               \Seen-on-FETCH behavior the old IMAP path got
               from plain RFC822 FETCH. With BODY.PEEK and the
               in-process MailSite path both leaving flags
               alone, the mark-read becomes an explicit step
               that fires for both backend types. A failure
               here is logged but not propagated: the user
               still sees the message, the read state will
               catch up on the next interaction. */
            try {
                $backend->setFlag($folder, $uid, '\\Seen', true);
            } catch (ML\MailBackendException $exception) {
                $this->userMailLog('mark-read-fail',
                    ['folder' => $folder, 'uid' => $uid,
                    'err' => $exception->getMessage()]);
            }
        } catch (ML\MailBackendException $exception) {
            $data["IMAP_ERROR"] = $parent->clean(
                $exception->getMessage(), "string", "");
        } finally {
            $backend->close();
        }
        ML\MailUnreadProbe::invalidate($data["USER_ID"]);
        unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
        $this->userMailDecorateTrustSender($data, $backend, $folder);
        return $data;
    }
    /**
     * userMailDecorateTrustSender decides whether to offer a "trust this
     * sender" control on the message view and supplies the data the template
     * needs for it. Two mutually exclusive notices are possible, and both apply
     * only under the Spam Insecure posture, only for the local MailSite
     * account, and only for a message that arrived without TLS (read from the
     * delivery Received header): - in the Junk folder, when the sender is not
     * already trusted, a notice offering to trust the sender (which moves the
     * message to the inbox); - in any other folder, a notice offering to
     * untrust the sender and send this and future mail to Junk. The envelope
     * sender is read from the Return-Path the delivery server recorded, since
     * that is the address the spam routing keyed on and need not match the From
     * header. Sets SHOW_TRUST_SENDER or SHOW_UNTRUST_SENDER accordingly, with
     * TRUST_SENDER carrying the cleaned address for display.
     * @param array &$data message-view data to add fields to
     * @param object $backend the mail backend for the account
     * @param string $folder the folder the message is in
     */
    protected function userMailDecorateTrustSender(&$data, $backend,
        $folder)
    {
        $parent = $this->parent;
        $data["SHOW_TRUST_SENDER"] = false;
        $data["SHOW_UNTRUST_SENDER"] = false;
        $posture = C\nsdefined("MAIL_DELIVERY_SECURITY") ?
            C\p('MAIL_DELIVERY_SECURITY') : 'insecure';
        if ($posture !== 'spam' || !$backend->isSyntheticAccount()) {
            return;
        }
        $bytes = $data["MESSAGE_BODY"] ?? '';
        $insecure_body = (string) $bytes;
        $insecure_break = strpos($insecure_body, "\r\n\r\n");
        if ($insecure_break === false) {
            $insecure_break = strpos($insecure_body, "\n\n");
        }
        $insecure_header = ($insecure_break === false) ?
            $insecure_body :
            substr($insecure_body, 0, $insecure_break);
        $arrived_insecurely = true;
        if (preg_match('/^Received:.*?\swith\s+(\S+)/mis',
            $insecure_header, $insecure_match)) {
            /* A message this server delivered to a local mailbox itself
               never crossed the network, so it cannot have been read in
               passing and is not insecure. Anything that came over the
               wire is secure only where the hop used TLS, which the
               ESMTPS keyword marks. */
            $insecure_with = strtoupper($insecure_match[1]);
            $arrived_insecurely = strpos($insecure_with, 'ESMTPS') !== 0
                && strpos($insecure_with, 'LOCAL') !== 0;
        }
        if (!$arrived_insecurely) {
            return;
        }
        $sender = MailHeaderParser::envelopeSender($bytes);
        if ($sender === '') {
            return;
        }
        $data["TRUST_SENDER"] = $parent->clean($sender, "string", "");
        $allow_model = $parent->model("mailSenderAllow");
        if ($folder === ML\MailSiteFactory::JUNK_FOLDER) {
            if (!$allow_model->isAllowed($data["USER_ID"], $sender)) {
                $data["SHOW_TRUST_SENDER"] = true;
            }
            return;
        }
        $data["SHOW_UNTRUST_SENDER"] = true;
    }
    /**
     * userMailTrustSender adds the envelope sender of a Junk message to the
     * signed-in user's trusted-sender list and moves that message to the inbox.
     * Used by the "trust this sender" control the message view offers for mail
     * that the Spam Insecure posture filed in Junk for arriving without TLS.
     * The sender address is taken from the stored message's Return-Path rather
     * than from the request, so a request cannot trust an arbitrary address;
     * the move and the list addition are both scoped to the signed-in user.
     * Expected request parameters: account_id (int), uid (int), folder (the
     * Junk folder the message is in).
     * @return mixed redirectWithMessage result
     */
    protected function userMailTrustSender()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        if ($uid <= 0 || $folder === '') {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_account'));
        }
        $backend = null;
        $sender = '';
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $bytes = $backend->fetchMessage($folder, $uid);
            $sender = MailHeaderParser::envelopeSender($bytes);
            if ($sender !== '') {
                $allow_model = $parent->model("mailSenderAllow");
                $allow_model->addSender($data["USER_ID"], $sender);
                $backend->moveMessage($folder, $uid, "INBOX");
                $this->userMailLog('trust-sender',
                    ['folder' => $folder, 'uid' => $uid]);
            }
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage(),
                ['arg', 'account_id', 'folder']);
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
        ML\MailUnreadProbe::invalidate($data["USER_ID"]);
        unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
        $message = ($sender === '') ?
            tl('social_component_trust_sender_none') :
            tl('social_component_trust_sender_done', $sender);
        $_REQUEST['arg'] = 'listMessages';
        $_REQUEST['folder'] = $folder;
        return $parent->redirectWithMessage($message,
            ['arg', 'account_id', 'folder']);
    }
    /**
     * userMailUntrustSender removes the envelope sender of a message from the
     * signed-in user's trusted-sender list and moves that message to Junk. Used
     * by the "do not trust" control the message view offers in a non-Junk
     * folder for mail that arrived without TLS under the Spam Insecure posture:
     * it both files this message in Junk and, by dropping the sender from the
     * trusted-sender list, lets future mail from that sender be junked again.
     * The sender address is taken from the stored message's Return-Path rather
     * than from the request, so a request cannot untrust an arbitrary address;
     * the move and the list change are both scoped to the signed-in user.
     * Expected request parameters: account_id (int), uid (int), folder (the
     * source folder).
     * @return mixed redirectWithMessage result
     */
    protected function userMailUntrustSender()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        if ($uid <= 0 || $folder === '') {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_account'));
        }
        $backend = null;
        $sender = '';
        try {
            $backend = $this->userMailBackend($account_id, $data);
            $bytes = $backend->fetchMessage($folder, $uid);
            $sender = MailHeaderParser::envelopeSender($bytes);
            if ($sender !== '') {
                $allow_model = $parent->model("mailSenderAllow");
                $allow_model->removeSender($data["USER_ID"], $sender);
                $backend->moveMessage($folder, $uid,
                    ML\MailSiteFactory::JUNK_FOLDER);
                $this->userMailLog('untrust-sender',
                    ['folder' => $folder, 'uid' => $uid]);
            }
        } catch (ML\MailBackendException $exception) {
            return $parent->redirectWithMessage(
                $exception->getMessage(),
                ['arg', 'account_id', 'folder']);
        } finally {
            if ($backend !== null) {
                $backend->close();
            }
        }
        ML\MailUnreadProbe::invalidate($data["USER_ID"]);
        unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
        $message = ($sender === '') ?
            tl('social_component_trust_sender_none') :
            tl('social_component_untrust_sender_done', $sender);
        $_REQUEST['arg'] = 'listMessages';
        $_REQUEST['folder'] = $folder;
        return $parent->redirectWithMessage($message,
            ['arg', 'account_id', 'folder']);
    }
    /**
     * userMailEditMailsite builds the MailSite account properties pane data,
     * reached from the edit pencil on the local MailSite account row. Supplies
     * the signed-in user's current aliases and the configured mail domains for
     * the add form.
     * @return array the mail-view data with CONTENT_PANE editMailsite
     */
    protected function userMailEditMailsite()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $data["CONTENT_PANE"] = "editMailsite";
        $username = $_SESSION["USER_NAME"] ?? '';
        $domains = ML\MailSiteFactory::localDomains();
        $first_domain = empty($domains) ? 'localhost' : $domains[0];
        $data["MAILSITE_DISPLAY_NAME"] = ($username === '') ? '' :
            $parent->clean($username . '@' . $first_domain,
            "string", "");
        $alias_model = $parent->model("mailAlias");
        $aliases = $alias_model->aliasesForUser($data["USER_ID"]);
        $data["ALIASES"] = [];
        foreach ($aliases as $entry) {
            $local = $parent->clean($entry["ALIAS"], "string", "");
            $alias_domain = $parent->clean($entry["DOMAIN"],
                "string", "");
            $data["ALIASES"][] = [
                'ALIAS' => $local,
                'DOMAIN' => $alias_domain,
                'ADDRESS' => $local . '@' . $alias_domain];
        }
        $data["MAIL_DOMAINS_LIST"] = [];
        foreach ($domains as $domain) {
            $data["MAIL_DOMAINS_LIST"][] =
                $parent->clean($domain, "string", "");
        }
        if (!empty($_SESSION["MAIL_ALIAS_ERROR"])) {
            $data["ALIAS_ERROR"] = $parent->clean(
                $_SESSION["MAIL_ALIAS_ERROR"], "string", "");
            unset($_SESSION["MAIL_ALIAS_ERROR"]);
        }
        return $data;
    }
    /**
     * userMailAliasAction adds or removes one alias for the signed-in user,
     * then returns to the MailSite properties pane. The alias local-part comes
     * from the request; the chosen domain only affects the confirmation
     * message, since an alias is stored once and is valid at every configured
     * mail domain. An add that collides with an account name or another user's
     * alias is rejected with a message. The change is scoped to the signed-in
     * user.
     * @return mixed redirectWithMessage result
     */
    protected function userMailAliasAction()
    {
        $parent = $this->parent;
        $user_id = $_SESSION["USER_ID"];
        $action = $parent->clean(
            $_REQUEST["alias_action"] ?? "", "string", "");
        $alias = $parent->clean(
            $_REQUEST["alias"] ?? "", "string", "");
        $domain = strtolower($parent->clean(
            $_REQUEST["alias_domain"] ?? "", "string", ""));
        $alias_model = $parent->model("mailAlias");
        if (!in_array($domain, ML\MailSiteFactory::localDomains())) {
            $_SESSION["MAIL_ALIAS_ERROR"] =
                tl("social_component_alias_bad_domain");
            $_REQUEST["arg"] = "editMailsite";
            return $parent->redirectWithMessage("", ["arg"]);
        }
        $address = $alias . '@' . $domain;
        if ($action === "remove") {
            $alias_model->removeAlias($user_id, $alias, $domain);
        } else if ($action === "add") {
            if (!$alias_model->addAlias($user_id, $alias, $domain)) {
                $_SESSION["MAIL_ALIAS_ERROR"] = tl(
                    "social_component_alias_unavailable", $address);
            }
        }
        $_REQUEST["arg"] = "editMailsite";
        return $parent->redirectWithMessage("", ["arg"]);
    }
    /**
     * userMailFromOptions builds the unified list of From identities for the
     * compose dropdown: one entry per address the signed-in user may send as.
     * For the local MailSite account this is the primary address and each owned
     * alias address; for each external mail account it is that account's email.
     * Every entry carries the owning account id and the address; the option
     * value joins them as "<account_id>|<address>" so the send handler can
     * route to the right account and, for the local account, the right
     * identity. The label is the bare email address, not the account's
     * shorthand display name.
     * @param array $data mail data carrying USER_ID, ACCOUNTS, and
     *     MAILSITE_ENABLED
     * @return array list of ['VALUE' => ..., 'ADDRESS' => ..., 'ACCOUNT_ID' =>
     *     int]
     */
    protected function userMailFromOptions($data)
    {
        $parent = $this->parent;
        $options = [];
        if (!empty($data["MAILSITE_ENABLED"])) {
            $backend = $this->userMailBackend(self::MAILSITE_ACCOUNT_ID, $data);
            $primary = $backend->senderEmail();
            $backend->close();
            foreach ($parent->model("mailAlias")->identitiesFor(
                $data["USER_ID"], $primary) as $address) {
                $clean = $parent->clean($address, "string", "");
                $options[] = [
                    'VALUE' => self::MAILSITE_ACCOUNT_ID . '|' .
                        $clean,
                    'ADDRESS' => $clean,
                    'ACCOUNT_ID' => self::MAILSITE_ACCOUNT_ID];
            }
        }
        foreach (($data["ACCOUNTS"] ?? []) as $entry) {
            if (!empty($entry["IS_MAILSITE"])) {
                continue;
            }
            $aid = (int) ($entry["ID"] ?? 0);
            $address = $parent->clean(
                MailAccountModel::senderEmail($entry),
                "string", "");
            if ($address === '') {
                continue;
            }
            $options[] = [
                'VALUE' => $aid . '|' . $address,
                'ADDRESS' => $address,
                'ACCOUNT_ID' => $aid];
        }
        return $options;
    }
    /**
     * userMailBuildMessageView builds the HTML-cleaned view-model the message-
     * view template echoes, so MailElement can emit message content raw. The
     * parsed MimeMessage stays untouched (it is a message model, not a view
     * model, and its body_html is sanitized separately by HtmlSanitizer at
     * render time); this returns a plain array of cleaned display strings:
     * HEADERS    map of subject/from/to/cc/date, each cleaned BODY_TEXT  the
     * plaintext part, cleaned BODY_RAW   the full the standard numbered 822
     * source, cleaned (raw-
     * view <pre>) ATTACHMENTS list of {FILENAME_HTML, EXT_LABEL} in the
     * original attachment order so the view can pair each with the matching
     * $index for its download URL
     * @param object $mime the parsed MimeMessage
     * @param string $raw_bytes the full the standard numbered 822 message
     *     source
     * @return array the cleaned view-model
     */
    protected function userMailBuildMessageView($mime, $raw_bytes)
    {
        $parent = $this->parent;
        $headers = $mime->headers;
        $clean_headers = [];
        foreach (['subject', 'from', 'to', 'cc', 'date']
                as $header_key) {
            $clean_headers[$header_key] = $parent->clean(
                $headers[$header_key] ?? '', "string", "");
        }
        $attachments = [];
        foreach ($mime->attachments as $attachment) {
            $filename = $attachment['filename'] ?? '';
            $dot = strrpos($filename, '.');
            if ($dot === false || $dot === strlen($filename) - 1) {
                $ext_label = 'FILE';
            } else {
                $ext_label = substr(strtoupper(substr($filename,
                    $dot + 1)), 0, 4);
            }
            $attachments[] = [
                "FILENAME_HTML" => $parent->clean($filename,
                    "string", ""),
                "EXT_LABEL" => $parent->clean($ext_label,
                    "string", ""),
            ];
        }
        return [
            "HEADERS" => $clean_headers,
            "BODY_TEXT" => $parent->clean($mime->body_text,
                "string", ""),
            "BODY_RAW" => $parent->clean((string) $raw_bytes,
                "string", ""),
            "ATTACHMENTS" => $attachments,
        ];
    }
    /**
     * userMailDownloadAttachment streams a single attachment from a named
     * message as an HTTP download. Refetches the full the standard numbered
     * 822 message, parses
     * it, locates the attachment at the given index, sends Content-Type and
     * Content-Disposition: attachment headers, writes the decoded bytes to the
     * response body, and exits the framework dispatch so nothing else writes to
     * the response. Required request parameters: account_id (int), uid (int),
     * index (int >= 0), folder (imap_arg, optional -- defaults to the account's
     * DEFAULT_FOLDER or INBOX). Errors (bad params, missing account, IMAP
     * failure, index out of range) redirect back to the account list with a
     * message.
     * @return mixed null when the download succeeded (response has already been
     *     written and exit called), or the result of redirectWithMessage on
     *     error
     */
    protected function userMailDownloadAttachment()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean($_REQUEST['account_id'] ?? null,
            'int', 0);
        $uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
        $index = $parent->clean($_REQUEST['index'] ?? null,
            'int', -1);
        $folder = $parent->clean($_REQUEST['folder'] ?? null,
            'imap_arg', '');
        if ($uid <= 0 || $index < 0) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            if ($folder === '') {
                $folder = $backend->defaultFolder();
            }
            $raw = $backend->fetchMessage($folder, $uid);
        } catch (ML\MailBackendException $exception) {
            if ($backend !== null) {
                $backend->close();
            }
            return $parent->redirectWithMessage(
                $exception->getMessage());
        }
        $backend->close();
        $mime = MimeMessage::parse($raw);
        if (!isset($mime->attachments[$index])) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $this->userMailStreamAttachment(
            $mime->attachments[$index]);
        return null;
    }
    /**
     * userMailStreamAttachment streams an attachment as an HTTP download
     * response. Writes Content-Type from the attachment's declared mime type
     * (falling back to application/octet-stream), Content-Length from the byte
     * count, Content-Disposition: attachment with a filename parameter so
     * browsers always save rather than try to display the bytes inline. Calls
     * exit after the body is written, terminating the framework dispatch. The
     * filename is sanitised to prevent header-injection (stripping CRLF and
     * double-quote) and falls back to a generic name if the attachment had
     * none. A future enhancement could use the standard numbered 5987
     * filename* encoding for full
     * Unicode support; today non-ASCII filenames fall back to the generic name
     * to stay within ASCII-safe header bytes.
     * @param array $attachment one entry from MimeMessage's attachments list:
     *     filename, mime_type, size, content
     */
    protected function userMailStreamAttachment($attachment)
    {
        $parent = $this->parent;
        $mime_type = $attachment['mime_type'] ?:
            'application/octet-stream';
        $raw_name = $attachment['filename'] ?: 'attachment';
        /* strip CRLF / quotes that would let an attacker inject
           extra headers; also reject the value if it is empty or
           contains non-ASCII (a future patch can switch to
           filename* / RFC 5987 to keep Unicode names). */
        $safe_name = str_replace(["\r", "\n", '"'], '', $raw_name);
        if ($safe_name === '' ||
            preg_match('/[\x80-\xFF]/', $safe_name)) {
            $safe_name = 'attachment';
        }
        $parent->web_site->header('Content-Type: ' . $mime_type);
        $parent->web_site->header('Content-Length: ' .
            $attachment['size']);
        $parent->web_site->header(
            'Content-Disposition: attachment; filename="' .
            $safe_name . '"');
        $parent->web_site->header(
            'X-Content-Type-Options: nosniff');
        e($attachment['content']);
        \seekquarry\atto\webExit();
    }
    /**
     * userMailDownloadAllAttachments streams a zip containing every attachment
     * from a message as an HTTP download. Re-fetches the full the standard
     * numbered 822 via IMAP
     * (same path as userMailDownloadAttachment), parses it, packs every
     * attachment into a zip via ZipArchive (filenames deduplicated by numeric
     * suffix so two attachments with the same display name still both end up in
     * the archive), and streams the zip bytes. The zip name is derived from the
     * message subject, sanitised to ASCII letters/digits/-_; an empty subject
     * yields the generic "attachments.zip". Required request parameters:
     * account_id (int), uid (int), folder (imap_arg, optional). Errors (bad
     * params, missing account, IMAP failure, message with no attachments)
     * redirect back with a message rather than streaming an empty or malformed
     * zip.
     * @return mixed null when streamed (response written and exit called), or
     *     the result of redirectWithMessage on error
     */
    protected function userMailDownloadAllAttachments()
    {
        $parent = $this->parent;
        $data = $this->userMailBaseData();
        $account_id = $parent->clean(
            $_REQUEST['account_id'] ?? null, 'int', 0);
        $uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
        $folder = $parent->clean(
            $_REQUEST['folder'] ?? null, 'imap_arg', '');
        if ($uid <= 0) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $backend = null;
        try {
            $backend = $this->userMailBackend($account_id, $data);
            if ($folder === '') {
                $folder = $backend->defaultFolder();
            }
            $raw = $backend->fetchMessage($folder, $uid);
        } catch (ML\MailBackendException $exception) {
            if ($backend !== null) {
                $backend->close();
            }
            return $parent->redirectWithMessage(
                $exception->getMessage());
        }
        $backend->close();
        $mime = MimeMessage::parse($raw);
        if (empty($mime->attachments)) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_account"));
        }
        $this->userMailStreamAttachmentsZip(
            $mime->headers['subject'] ?? '',
            $mime->attachments);
        return null;
    }
    /**
     * userMailStreamAttachmentsZip builds an in-disk zip containing the given
     * attachments, streams it as a binary download, then deletes the temp file
     * and exits the framework dispatch. ZipArchive is a core PHP extension so
     * this is not a new dependency. Duplicate filenames inside one message are
     * handled by appending " (N)" before the extension; the first occurrence
     * keeps its name. The zip is named from the message subject (sanitised to
     * ASCII letters/digits/-_), or "attachments" when no usable subject is
     * available.
     * @param string $subject the message Subject header (used to derive the zip
     *     filename)
     * @param array $attachments list from MimeMessage::parse
     */
    protected function userMailStreamAttachmentsZip($subject,
        $attachments)
    {
        $parent = $this->parent;
        $zip_name = preg_replace('/[^A-Za-z0-9._-]+/', '_',
            trim($subject));
        $zip_name = trim($zip_name, '_');
        if ($zip_name === '') {
            $zip_name = 'attachments';
        }
        $zip_name = substr($zip_name, 0, 80) . '.zip';
        $tmp_path = tempnam(sys_get_temp_dir(), 'yioop_attach_');
        if ($tmp_path === false) {
            return;
        }
        $zip = new \ZipArchive();
        if ($zip->open($tmp_path, \ZipArchive::CREATE |
            \ZipArchive::OVERWRITE) !== true) {
            unlink($tmp_path);
            return;
        }
        $seen_names = [];
        foreach ($attachments as $attach) {
            $name = $attach['filename'] ?: 'attachment';
            /* dedupe within the archive: same filename appearing
               twice in one message gets " (2)", " (3)" etc.
               appended before the extension. */
            $unique = $name;
            $copy = 2;
            while (isset($seen_names[$unique])) {
                $dot = strrpos($name, '.');
                if ($dot === false) {
                    $unique = $name . ' (' . $copy . ')';
                } else {
                    $unique = substr($name, 0, $dot) . ' (' .
                        $copy . ')' . substr($name, $dot);
                }
                $copy++;
            }
            $seen_names[$unique] = true;
            $zip->addFromString($unique, $attach['content']);
        }
        $zip->close();
        $size = filesize($tmp_path);
        $bytes = file_get_contents($tmp_path);
        unlink($tmp_path);
        $parent->web_site->header('Content-Type: application/zip');
        $parent->web_site->header('Content-Length: ' . $size);
        $parent->web_site->header(
            'Content-Disposition: attachment; filename="' .
            $zip_name . '"');
        $parent->web_site->header(
            'X-Content-Type-Options: nosniff');
        e($bytes);
        \seekquarry\atto\webExit();
    }
    /**
     * userMailBackend builds the right mail backend for an account id, loading
     * the account row first so the backend factory never has to reach into a
     * model itself. The synthetic MailSite account needs no row; for a real
     * account this looks up the owned row with its decrypted password and hands
     * it to the factory.
     * @param int $account_id the account to build a backend for;
     *     MAILSITE_ACCOUNT_ID selects the synthetic local account
     * @param array $data the userMailBaseData() context, read for USER_ID,
     *     USER_NAME, and MAILSITE_ENABLED
     * @return object a MailBackend bound to the account, ready to connect on
     *     first use
     * @throws object a MailBackendException when the id resolves to neither the
     *     synthetic account nor an owned IMAP row
     */
    protected function userMailBackend($account_id, $data)
    {
        $account = $this->parent->model("MailAccount")
            ->getAccountWithPassword($account_id, $data["USER_ID"]);
        return ML\MailBackend::forAccountId($account_id, $data,
            $account, $this);
    }
    /**
     * userMailBaseData returns the $data scaffold every Mail view starts from.
     * @return array $data with CONTROLLER, ELEMENT, SCRIPT, INCLUDE_STYLES,
     *     MAIL_MODE, EXTERNAL_ENABLED, MAILSITE_ENABLED, USER_ID, MESSAGES,
     *     ACCOUNTS populated
     */
    protected function userMailBaseData()
    {
        $parent = $this->parent;
        $mail_mode = C\nsdefined("MAIL_MODE") ? C\p('MAIL_MODE') : 'disabled';
        $controller_name = (get_class($parent) == C\NS_CONTROLLERS .
            "AdminController") ? "admin" : "group";
        $data = [];
        $data["CONTROLLER"] = $controller_name;
        $data["ELEMENT"] = "mail";
        $data["SCRIPT"] = "";
        $data["INCLUDE_STYLES"] = ["messages", "mail"];
        $data["INCLUDE_SCRIPTS"] ??= [];
        $data["INCLUDE_SCRIPTS"][] = "mailmessages";
        /* The compose screen checks what it can before sending the form,
           so a fault it can see is said without the form being sent and
           coming back with the chosen file gone. It needs the same
           limits and the same mail domain the send checks against. */
        $data["ATTACH_MAX_COUNT"] = self::MAIL_ATTACH_MAX_COUNT;
        $data["ATTACH_MAX_BYTES"] = self::MAIL_ATTACH_MAX_BYTES;
        $data["LOCAL_MAIL_DOMAIN"] =
            MailSiteFactory::localDomains()[0] ?? "";
        $data["INCLUDE_SCRIPTS"][] = "mailclone";
        $data["SUBTITLE"] = C\PERSONAL_GROUP_PREFIX;
        $data["MAIL_MODE"] = $mail_mode;
        $data["EXTERNAL_ENABLED"] = in_array($mail_mode,
            ['external_mail', 'both']);
        $data["MAILSITE_ENABLED"] = in_array($mail_mode,
            ['mailsite', 'both']);
        $data["USER_ID"] = $_SESSION['USER_ID'];
        $data["USER_NAME"] = $parent->clean(
            $_SESSION['USER_NAME'] ?? '', "string", "");
        $data["MOBILE"] = !empty($_SERVER["MOBILE"]);
        /* Refresh the unread-mail badge here, inside the Mail
           activity, where talking to the mail servers is expected
           and the listing already connects. The shared page chrome
           only reads the cached value (cachedCount), so no general
           page ever waits on a slow mailbox to paint the badge; it
           catches up the next time the user opens their mail. This
           call is a no-op unless external mail is on and the cached
           badge has expired. */
        $mail_badge_user = (int) $data["USER_ID"];
        ML\MailUnreadProbe::count($mail_badge_user,
            function () use ($parent, $mail_badge_user) {
                return $parent->model("MailAccount")
                    ->getAccountsWithPassword($mail_badge_user);
            });
        $data["MESSAGES"] = [];
        $data["ACCOUNTS"] = [];
        /* Active clone job (if any) is surfaced here so every
           mail page can paint the cloning account's sidebar row
           with the cloning-link badge, not just the
           editAccount page where the clone gets started. */
        $clone_model = $parent->model("MailClone");
        $data["ACTIVE_CLONE_JOB"] =
            $clone_model->activeJobForUser($_SESSION['USER_ID']);
        /* CURRENT_FOLDER on the job row is a mailbox path the
           clone walked off the remote server; the status banner
           echoes it, so HTML-clean it here and let the view emit
           it raw. (The 5-second JS poll sets the same value via
           textContent, which needs no escaping.) */
        if (!empty($data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"])) {
            $data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"] =
                $parent->clean(
                $data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"],
                "string", "");
        }
        /* When the latest clone job failed (and no live job has
           since replaced it), surface it separately so the status
           banner can offer a Retry. Kept out of ACTIVE_CLONE_JOB on
           purpose: that key drives the single-job limit and the
           "cloning" sidebar badge, which should track only live
           work. CURRENT_FOLDER is echoed by the banner, so clean
           it the same way. */
        $data["FAILED_CLONE_JOB"] = empty($data["ACTIVE_CLONE_JOB"])
            ? $clone_model->recentFailedJobForUser(
            $_SESSION['USER_ID']) : null;
        if (!empty($data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"])) {
            $data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"] =
                $parent->clean(
                $data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"],
                "string", "");
        }
        /* Template the clone-error "later succeeded via %s"
           string here so the element can emit it raw into a data
           attribute without escaping in the view. The %s is left
           in place for mailclone.js to substitute the rescue
           token client-side. */
        $data["CLONE_ERROR_RESOLVED_VIA_TEMPLATE"] =
            $parent->clean(tl(
            'mail_element_clone_error_resolved_via', '%s'),
            "string");
        /* per-account folder lists cached by userMailListMessages;
           lets every mail pane render the folder list, not just the
           inbox pane that fetched it */
        $data["FOLDERS_BY_ACCOUNT"] =
            $parent->model("Mail")->allCachedFolders();
        /* per-account collapsed state for the folder-list
           disclosure triangle; an account with no entry defaults to
           expanded */
        $data["ACCOUNT_COLLAPSED"] =
            $_SESSION["MAIL_ACCOUNT_COLLAPSED"] ?? [];
        /* per-folder expanded state for the tree-view disclosure
           arrows; a folder with no entry defaults to collapsed */
        $data["FOLDER_EXPANDED"] =
            $_SESSION["MAIL_FOLDER_EXPANDED"] ?? [];
        if ($data["MAILSITE_ENABLED"]) {
            $username = $_SESSION['USER_NAME'] ?? '';
            $domains = trim((string) C\p('MAIL_DOMAINS'));
            $first_domain = ($domains === '') ? 'localhost' :
                trim(strtok($domains, ','));
            $mailsite_email = ($username === '') ? '' :
                ($username . '@' . $first_domain);
            $data["ACCOUNTS"][] = [
                'ID' => self::MAILSITE_ACCOUNT_ID,
                'USER_ID' => $data["USER_ID"],
                'DISPLAY_NAME' => $mailsite_email,
                'PROVIDER' => 'mailsite',
                'HOST' => '',
                'USERNAME' => $username,
                'DEFAULT_FOLDER' => 'INBOX',
                'IS_MAILSITE' => true,
            ];
            /* Make sure the local MailSite account's folder list is
               available as a move destination even when the user
               has not opened that account this session. The
               backend is local (a directory listing, no network),
               so listing folders here is cheap; only fetch when the
               session cache has nothing for this account. */
            if (empty($data["FOLDERS_BY_ACCOUNT"]
                [self::MAILSITE_ACCOUNT_ID])) {
                try {
                    $mailsite_backend = $this->userMailBackend(
                        self::MAILSITE_ACCOUNT_ID, $data);
                    $mailsite_folders =
                        $mailsite_backend->listFolders();
                    $mailsite_backend->close();
                    $data["FOLDERS_BY_ACCOUNT"]
                        [self::MAILSITE_ACCOUNT_ID] =
                        $mailsite_folders;
                } catch (ML\MailBackendException $exception) {
                    $data["FOLDERS_BY_ACCOUNT"]
                        [self::MAILSITE_ACCOUNT_ID] = [];
                }
            }
        }
        if ($data["EXTERNAL_ENABLED"]) {
            $account_model = $parent->model("MailAccount");
            $data["ACCOUNTS"] = array_merge(
                $data["ACCOUNTS"],
                $account_model->getAccountsForUser(
                $data["USER_ID"]));
            $scheduled_model = $parent->model("MailScheduled");
            $rows = $scheduled_model->getMessagesForUser(
                $data["USER_ID"]);
            $by_account = [];
            $now = time();
            $any_due = false;
            foreach ($rows as $row) {
                $aid = (int) $row['ACCOUNT_ID'];
                $by_account[$aid] = ($by_account[$aid] ?? 0) + 1;
                if (!$any_due && (int) $row['SCHEDULED_AT'] <=
                        $now &&
                        ($row['STATUS'] ?? '') === 'pending') {
                    $any_due = true;
                }
            }
            $data["SCHEDULED_PENDING_BY_ACCOUNT"] = $by_account;
            if ($any_due && C\MAIL_SCHEDULED_LAZY_DISPATCH) {
                MailScheduledDispatcher::dispatch(
                    $scheduled_model, $account_model, $now);
            }
        }
        /* The account string fields below are echoed into the
           accounts sidebar and the account/compose forms, so
           HTML-clean them once here at the source and let
           MailElement emit them raw. DISPLAY_NAME, USERNAME, and
           HOST are user- or server-supplied; DEFAULT_FOLDER comes
           off the wire. */
        $clean_account_fields = ['DISPLAY_NAME', 'USERNAME',
            'HOST', 'DEFAULT_FOLDER'];
        foreach ($data["ACCOUNTS"] as $account_index => $account) {
            foreach ($clean_account_fields as $account_field) {
                if (isset($account[$account_field])) {
                    $data["ACCOUNTS"][$account_index][$account_field]
                        = $parent->clean(
                        $account[$account_field], "string", "");
                }
            }
        }
        /* wiki.js carries the split-adjuster drag handler that the
           two-pane layout shares with the Messages activity */
        $this->initializeWikiEditor($data, -1);
        /* every other activity calls initSocialBadges via its own
           render path; the Mail dispatch did not, leaving every
           badge (mail, messages, posts, groups) empty on the Mail
           page even though they populated everywhere else. Doing
           it here covers all the userMailBaseData callers. */
        $this->initSocialBadges($data["USER_ID"], $data);
        return $data;
    }
    /**
     * userMailParseAccountFields pulls submitted form fields into an
     * associative array shaped for MailAccountModel::addAccount /
     * updateAccount. Trims strings and coerces the ports to int. Covers both
     * the incoming (IMAP) and outgoing (SMTP) sections of the form plus the
     * per-account allow-self-signed-certificate check box.
     * @return array fields ready for the model
     */
    protected function userMailParseAccountFields()
    {
        /*
            A password field submitted as exactly the masked sentinel
            means "the stored credential was not touched": normalize
            it to the empty string here so the rest of the pipeline
            (validation, updateAccount's leave-password flags) treats
            it identically to a field the user left blank.
         */
        $password = $_REQUEST['password'] ?? '';
        if ($password === MailAccountModel::PASSWORD_SENTINEL) {
            $password = '';
        }
        $smtp_password = $_REQUEST['smtp_password'] ?? '';
        if ($smtp_password === MailAccountModel::PASSWORD_SENTINEL) {
            $smtp_password = '';
        }
        $provider = trim($_REQUEST['provider'] ?? '');
        if ($provider !== '' && !array_key_exists($provider,
                MailAccountModel::PROVIDER_PRESETS)) {
            $provider = '';
        }
        return [
            "DISPLAY_NAME" => trim($_REQUEST['display_name'] ?? ''),
            "PROVIDER" => $provider,
            "HOST" => trim($_REQUEST['host'] ?? ''),
            "PORT" => intval($_REQUEST['port'] ?? 993),
            "USERNAME" => trim($_REQUEST['username'] ?? ''),
            "PASSWORD" => $password,
            "TLS_MODE" => in_array($_REQUEST['tls_mode'] ?? '',
                ['imaps', 'starttls', 'plain']) ?
                $_REQUEST['tls_mode'] : 'imaps',
            "DEFAULT_FOLDER" => "INBOX",
            "ALLOW_SELF_SIGNED" =>
                empty($_REQUEST['allow_self_signed']) ? 0 : 1,
            "SMTP_HOST" => trim($_REQUEST['smtp_host'] ?? ''),
            "SMTP_PORT" => intval($_REQUEST['smtp_port'] ?? 587),
            "SMTP_USERNAME" => trim($_REQUEST['smtp_username'] ?? ''),
            "SMTP_PASSWORD" => $smtp_password,
            "SMTP_TLS_MODE" => in_array($_REQUEST['smtp_tls_mode'] ?? '',
                ['smtps', 'starttls', 'plain']) ?
                $_REQUEST['smtp_tls_mode'] : 'starttls',
        ];
    }
    /**
     * userMailValidateAccountFields returns an array of human-readable field-
     * level errors for the supplied account fields. An empty array means the
     * fields pass validation. The outgoing (SMTP) host is optional; when it is
     * left blank the SMTP section is simply not validated.
     * @param array $fields output of userMailParseAccountFields()
     * @param bool $password_required true on Add (must be present), false on
     *     Edit (empty means "keep stored password")
     * @return array map of field name => message
     */
    protected function userMailValidateAccountFields($fields,
        $password_required)
    {
        $errors = [];
        if (empty($fields['DISPLAY_NAME'])) {
            $errors['DISPLAY_NAME'] =
                tl("social_component_mail_field_required");
        }
        if (empty($fields['HOST'])) {
            $errors['HOST'] = tl("social_component_mail_field_required");
        }
        if (empty($fields['USERNAME'])) {
            $errors['USERNAME'] =
                tl("social_component_mail_field_required");
        }
        if ($password_required && empty($fields['PASSWORD'])) {
            $errors['PASSWORD'] =
                tl("social_component_mail_field_required");
        }
        if ($fields['PORT'] < 1 || $fields['PORT'] > 65535) {
            $errors['PORT'] = tl("social_component_mail_port_invalid");
        }
        if (!empty($fields['SMTP_HOST']) &&
            ($fields['SMTP_PORT'] < 1 || $fields['SMTP_PORT'] > 65535)) {
            $errors['SMTP_PORT'] =
                tl("social_component_mail_port_invalid");
        }
        return $errors;
    }
    /**
     * userMailDecorateFolderDisplay works out ahead of time the HTML-cleaned
     * display values
     * the folder list needs so MailElement can echo them raw. Folder NAME is
     * left untouched because it is reused verbatim to build folder URLs and
     * IMAP SELECT arguments, where HTML-escaping it would corrupt the mailbox
     * path; instead each entry gains: NAME_HTML      cleaned full name (for
     * data-folder-name) DELIMITER_HTML cleaned hierarchy delimiter DISPLAY_NAME
     * cleaned last path segment, brackets stripped A FOLDER_DISPLAY map (raw
     * full name => cleaned display) is also returned via $data so synthetic
     * parent rows inserted during tree building (e.g. Gmail's [Gmail] root) can
     * show a cleaned label without the view doing any cleaning. Every ancestor
     * prefix of every folder is seeded into the map for that reason.
     * @param array $data the mail $data array; $data['FOLDERS'] is decorated in
     *     place and $data['FOLDER_DISPLAY'] is set
     */
    protected function userMailDecorateFolderDisplay(&$data)
    {
        $parent = $this->parent;
        $folders = $data["FOLDERS"] ?? [];
        if (empty($folders)) {
            $data["FOLDER_DISPLAY"] = [];
            return;
        }
        /* dominant delimiter across the account, mirroring the
           choice MailElement makes when it splits names into
           segments; per-folder DELIMITER is preferred when set. */
        $delimiter_counts = [];
        foreach ($folders as $folder_info) {
            $hierarchy = $folder_info['DELIMITER'] ?? '';
            if ($hierarchy !== '') {
                $delimiter_counts[$hierarchy] =
                    ($delimiter_counts[$hierarchy] ?? 0) + 1;
            }
        }
        $dominant_delimiter = '/';
        if (!empty($delimiter_counts)) {
            arsort($delimiter_counts);
            $dominant_delimiter = array_key_first($delimiter_counts);
        }
        $display_map = [];
        foreach ($folders as $folder_index => $folder_info) {
            $raw_name = $folder_info['NAME'] ?? '';
            $row_delimiter = ($folder_info['DELIMITER'] ?? '') !== ''
                ? $folder_info['DELIMITER'] : $dominant_delimiter;
            $data["FOLDERS"][$folder_index]["NAME_HTML"] =
                $parent->clean($raw_name, "string", "");
            $data["FOLDERS"][$folder_index]["DELIMITER_HTML"] =
                $parent->clean($row_delimiter, "string", "");
            $display = $this->userMailFolderSegmentDisplay(
                $raw_name, $row_delimiter);
            $data["FOLDERS"][$folder_index]["DISPLAY_NAME"] =
                $parent->clean($display, "string", "");
            /* seed this folder and every ancestor prefix so a
               synthesized parent row can find a cleaned label. */
            $segments = explode($row_delimiter, $raw_name);
            $prefix = '';
            for ($segment_index = 0;
                $segment_index < count($segments); $segment_index++) {
                $prefix = ($segment_index === 0) ?
                    $segments[0] :
                    $prefix . $row_delimiter .
                    $segments[$segment_index];
                if (!isset($display_map[$prefix])) {
                    $display_map[$prefix] = [
                        "DISPLAY" => $parent->clean(
                        $this->userMailFolderSegmentDisplay(
                        $prefix, $row_delimiter), "string", ""),
                        "NAME_HTML" => $parent->clean($prefix,
                        "string", ""),
                        "DELIMITER_HTML" => $parent->clean(
                        $row_delimiter, "string", ""),
                    ];
                }
            }
        }
        $data["FOLDER_DISPLAY"] = $display_map;
    }
    /**
     * userMailFolderSegmentDisplay returns the user-facing label for a folder
     * path: the last segment after splitting on the hierarchy delimiter, with a
     * single pair of surrounding square brackets stripped (the Gmail / IMAP
     * namespace convention that wraps roots such as [Gmail]). The raw,
     * unstripped path is what callers keep for IMAP operations; this is
     * presentation only.
     * @param string $raw_name full folder path as reported by the server
     * @param string $delimiter hierarchy delimiter to split on
     * @return string the display label (not yet HTML-cleaned)
     */
    protected function userMailFolderSegmentDisplay($raw_name,
        $delimiter)
    {
        $segments = explode($delimiter, $raw_name);
        $display = end($segments);
        if (strlen($display) >= 2 && $display[0] === '[' &&
            substr($display, -1) === ']') {
            $display = substr($display, 1, -1);
        }
        return $display;
    }
    /**
     * userMailGetSort resolves and persists the current sort choice for an
     * inbox view. If the request supplies sort_key, that overrides the stored
     * choice and is written into the session; otherwise the session value for
     * this account+folder is returned. The default when nothing is stored is
     * ["key" => "date", "reverse" => false] which matches the historical
     * newest-first behavior and needs no SORT command. The key is restricted to
     * "date", "subject" or "from"; any other value falls through to the
     * default.
     * @param int $account_id the active mail account id
     * @param string $folder the active folder name
     * @return array ["key" => string, "reverse" => bool]
     */
    protected function userMailGetSort($account_id, $folder)
    {
        $parent = $this->parent;
        $allowed_keys = ["date", "subject", "from"];
        $mail_model = $parent->model("Mail");
        $requested_key = $_REQUEST['sort_key'] ?? null;
        if ($requested_key !== null) {
            $key = $parent->clean($requested_key, $allowed_keys,
                "date");
            $reverse = $parent->clean(
                $_REQUEST['sort_reverse'] ?? null, 'bool');
            $sort = ["key" => $key, "reverse" => $reverse];
            $mail_model->storeSort($account_id, $folder, $sort);
            return $sort;
        }
        $stored = $mail_model->storedSort($account_id, $folder);
        if (is_array($stored) &&
            in_array($stored["key"] ?? "", $allowed_keys, true)) {
            return ["key" => $stored["key"],
                "reverse" => !empty($stored["reverse"])];
        }
        return ["key" => "date", "reverse" => false];
    }
    /**
     * userMailGetFilter returns the trimmed and sanitized filter term from the
     * request, or the empty string when no filter is set. The value is cleaned
     * through the imap_arg type so it cannot carry CRLF (which would smuggle an
     * extra IMAP command) and is length-bounded. Unlike sort, filter is
     * deliberately not persisted in the session or scoped to the
     * account/folder: a non-empty value lives only as a URL parameter, and
     * folder links built elsewhere do not carry it, so switching folders clears
     * the filter naturally.
     * @return string the trimmed filter term, empty when absent
     */
    protected function userMailGetFilter()
    {
        $parent = $this->parent;
        $filter = $parent->clean($_REQUEST['filter'] ?? null,
            'imap_arg', '');
        return trim($filter);
    }
    /**
     * userMailGetUnreadOnly returns the current state of the unread-only filter
     * toggle as a bool. Reads the unread_only query parameter; absent or falsy
     * values mean "show all messages", any truthy value means "show only
     * messages without \Seen". Toggle state lives in URL parameters rather than
     * session so the existing browser-history affordances cover undo/redo for
     * the user.
     * @return bool true when only unread messages should be listed
     */
    protected function userMailGetUnreadOnly()
    {
        $parent = $this->parent;
        return (bool) $parent->clean(
            $_REQUEST['unread_only'] ?? null, 'bool', false);
    }
    /**
     * userMailGetFlaggedOnly returns the current state of the flagged-only
     * filter toggle as a bool. Reads the flagged_only query parameter; absent
     * or falsy values mean "show all messages," any truthy value means "show
     * only messages with \Flagged set." Same URL-only persistence as
     * userMailGetUnreadOnly.
     * @return bool true when only flagged messages should be listed
     */
    protected function userMailGetFlaggedOnly()
    {
        $parent = $this->parent;
        return (bool) $parent->clean(
            $_REQUEST['flagged_only'] ?? null, 'bool', false);
    }
    /**
     * userMailCleanEnvelopes hTML-cleans the echoed display fields (SUBJECT,
     * FROM, FROM_NAME) on an already-built list of message envelopes, so the
     * inbox view can emit them raw. Used for envelope lists that do not pass
     * through userMailParseEnvelopes -- chiefly the in-process MailSite
     * backend, whose listMessages returns structured rows directly. Idempotency
     * is not assumed: callers pass raw envelopes exactly once before render.
     * @param array $messages list of envelope arrays, cleaned in place
     * @return array the same list with cleaned display fields
     */
    protected function userMailCleanEnvelopes($messages)
    {
        $parent = $this->parent;
        foreach ($messages as $message_index => $message) {
            foreach (['SUBJECT', 'FROM', 'FROM_NAME']
                    as $envelope_field) {
                if (isset($message[$envelope_field])) {
                    $messages[$message_index][$envelope_field] =
                        $parent->clean($message[$envelope_field],
                        "string", "");
                }
            }
        }
        return $messages;
    }
    }
