/ src / executables / MailTool.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see
 * <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * Command line maintenance tool for MailSite's on-disk
 * (FileMailStorage) mail tree. Subcommands:
 *
 *   import <maildir_or_mbox_path> <user> <folder>
 *       Import a source into a MailSite folder, preserving IMAP
 *       flags and internal dates, with a copied/skipped tally. A
 *       directory is read as a dovecot Maildir folder (its cur/ and
 *       new/ subdirectories); a file is read as an mbox and split
 *       on its "From " postmark lines. Streams the source so large
 *       sources stay within a flat memory budget, and is resumable
 *       and idempotent via a per-folder import journal, so a re-run
 *       or an interrupted run does not duplicate messages.
 *
 *   export <user> <folder> <out_file>
 *       Export a user's local mail folder to an mbox file at
 *       out_file. Each message is written with a "From " postmark
 *       dated from its IMAP internal date, Status and X-Status
 *       headers carrying its stored flags so a later import
 *       recovers them, and ">From " quoting of body lines that
 *       begin with "From ". Streams the folder a message at a time
 *       so a large folder stays within a flat memory budget. The
 *       output file is overwritten if it already exists.
 *
 *   verify <user>
 *       Print the message count of every folder for a user.
 *
 * Run only while MailServer is stopped: import allocates UIDs,
 * which conflicts with a live daemon writing the same tree.
 *
 * Usage (from src/executables):
 *   php MailTool.php import <maildir_or_mbox_path> <user> <folder>
 *   php MailTool.php export <user> <folder> <out_file>
 *   php MailTool.php verify <user>
 *
 * @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\executables;

use seekquarry\yioop\configs as C;
use seekquarry\atto\FileMailStorage;
use seekquarry\atto\MailSite;

require_once __DIR__ . "/../configs/Config.php";

/**
 * Provides import, export, and verify maintenance subcommands
 * for the on-disk MailSite mail tree.
 *
 * @author Chris Pollett chris@pollett.org
 */
class MailTool
{
    /**
     * Maps a dovecot Maildir info flag letter to the IMAP system
     * flag MailSite stores: S Seen, R Replied (Answered), F
     * Flagged, T Trashed (Deleted), D Draft.
     * @var array
     */
    public $maildir_flags = [
        'S' => MailSite::FLAG_SEEN,
        'R' => MailSite::FLAG_ANSWERED,
        'F' => MailSite::FLAG_FLAGGED,
        'T' => MailSite::FLAG_DELETED,
        'D' => MailSite::FLAG_DRAFT,
    ];
    /**
     * Dispatches to the subcommand named in the arguments.
     *
     * @param array $argv command line arguments
     * @return void
     */
    public function run($argv)
    {
        $command = $argv[1] ?? "";
        if ($command === "import") {
            $this->import($argv);
        } else if ($command === "export") {
            $this->export($argv);
        } else if ($command === "verify") {
            $this->verify($argv);
        } else {
            $this->usage();
        }
    }
    /**
     * Imports a dovecot Maildir folder into a MailSite account.
     *
     * Streams the source messages one at a time (never building a
     * list of all of them) so a folder with tens of thousands of
     * messages imports within a flat memory budget. Resumable and
     * idempotent via a per-folder import journal of done source
     * basenames, written before each append and kept after a
     * successful run: a re-run skips already-imported messages
     * rather than duplicating them, and a run interrupted partway
     * resumes where it stopped. Subscribes the target folder (the
     * storage-layer createFolder does not subscribe the way IMAP
     * CREATE does, and LSUB filters to subscribed folders) and
     * primes the folder's flags snapshot. Reports copied and
     * skipped counts separately.
     *
     * @param array $argv command line arguments
     * @return void
     */
    public function import($argv)
    {
        if (count($argv) < 5) {
            $this->out("Usage: php MailTool.php import " .
                "<maildir_or_mbox_path> <user> <folder>");
            return;
        }
        $source = $argv[2];
        $user = $argv[3];
        $folder = $argv[4];
        $is_mbox = !is_dir($source) && is_file($source);
        if (!is_dir($source) && !$is_mbox) {
            $this->out("ERROR: source not found: $source");
            return;
        }
        $storage = new FileMailStorage(C\MAIL_DIR);
        $storage->ensureUser($user);
        if (!$storage->folderExists($user, $folder)) {
            if (!$storage->createFolder($user, $folder)) {
                $this->out("ERROR: could not create target " .
                    "folder \"$folder\" for $user");
                return;
            }
        }
        $this->outAt("Scanning $source ...");
        $expected = $is_mbox ? $this->countMboxMessages($source) :
            $this->countMessages($source);
        $this->outAt("Found $expected message(s) under $source");
        /*
            Stream the source one message at a time rather than
            building an array of every message up front, which on a
            folder of tens of thousands of messages exhausted
            memory. A journal of the source filenames already
            imported lets a run interrupted by a crash resume:
            messages whose basename is in the journal are skipped,
            and the journal is removed only on clean completion.
         */
        $journal_path = $this->importJournalPath($user, $folder);
        $done = $this->readImportJournal($journal_path);
        if (!empty($done)) {
            $this->outAt("Resuming: " . count($done) .
                " message(s) already imported are skipped");
        }
        $journal = @fopen($journal_path, "a");
        $copied = 0;
        $skipped = 0;
        $failures = [];
        $processed = 0;
        $messages = $is_mbox ? $this->eachMboxMessage($source) :
            $this->eachMessage($source);
        foreach ($messages as $message) {
            $processed++;
            $name = isset($message['path']) ?
                basename($message['path']) : $message['key'];
            if (isset($done[$name])) {
                $skipped++;
                continue;
            }
            if (isset($message['bytes'])) {
                $bytes = $message['bytes'];
            } else if (is_readable($message['path'])) {
                $bytes = file_get_contents($message['path']);
            } else {
                $bytes = false;
            }
            if ($bytes === false || $bytes === '') {
                $label = $message['path'] ?? $name;
                $failures[] = "$label (unreadable or empty)";
                continue;
            }
            $uid = $storage->appendMessage($user, $folder, $bytes,
                $message['flags'], $message['internal_date']);
            if ($uid === false) {
                $failures[] = $message['path'] . " (appendMessage " .
                    "failed)";
                continue;
            }
            $copied++;
            if ($journal !== false) {
                fwrite($journal, $name . "\n");
            }
            if ($processed % 1000 === 0) {
                $this->outAt(sprintf("  copied %d (skipped %d) " .
                    "of %d ...", $copied, $skipped, $expected));
            }
        }
        if ($journal !== false) {
            fclose($journal);
        }
        /*
            Subscribe the folder so clients that list via LSUB
            (Apple Mail, Thunderbird) show it; createFolder at the
            storage layer does not subscribe the way the IMAP
            CREATE command does. Prime the flags snapshot too so
            the rebuild fallback exists right after import rather
            than only after the first compaction.
         */
        $storage->subscribe($user, $folder);
        $storage->refreshFlagsSnapshot($user, $folder);
        /*
            The journal is kept after a clean run, not deleted, so
            re-running the same import recognizes every message as
            already imported and skips it rather than appending a
            second copy. It is the durable record of which source
            messages this folder already holds.
         */
        $this->reportImport($expected, $copied, $skipped,
            $failures);
    }
    /**
     * Absolute path to the per-folder import journal, which records
     * the source filenames already imported so an interrupted
     * import can resume. Kept under the user directory keyed by a
     * sanitized folder name so it never collides with message or
     * folder metadata files.
     *
     * @param string $user username (no @domain)
     * @param string $folder target folder name
     * @return string path to the import journal file
     */
    public function importJournalPath($user, $folder)
    {
        $safe_folder = preg_replace('/[^A-Za-z0-9._-]/', '_',
            $folder);
        return C\MAIL_DIR . "/users/" . $this->safeName($user) .
            "/import-" . $safe_folder . ".journal";
    }
    /**
     * Reads an import journal into a set of already-imported source
     * filenames, or an empty set when no journal is present.
     *
     * @param string $path import journal path
     * @return array set of basenames mapped to true
     */
    public function readImportJournal($path)
    {
        $done = [];
        if (!is_file($path)) {
            return $done;
        }
        $handle = @fopen($path, "rb");
        if ($handle === false) {
            return $done;
        }
        while (($line = fgets($handle)) !== false) {
            $line = rtrim($line, "\r\n");
            if ($line !== "") {
                $done[$line] = true;
            }
        }
        fclose($handle);
        return $done;
    }
    /**
     * Prints the message count of every folder for a user, so an
     * import or migration can be checked against expected totals.
     *
     * @param array $argv command line arguments
     * @return void
     */
    public function verify($argv)
    {
        if (count($argv) < 3) {
            $this->out("Usage: php MailTool.php verify <user>");
            return;
        }
        $user = $argv[2];
        $storage = new FileMailStorage(C\MAIL_DIR);
        if (!is_dir(C\MAIL_DIR . "/users/" .
            $this->safeName($user))) {
            $this->out("ERROR: no mail directory for user $user");
            return;
        }
        $total = 0;
        foreach ($storage->listFolders($user) as $folder) {
            $count = $storage->messageCount($user, $folder);
            $total += $count;
            $this->out(sprintf("  %-40s %d", $folder, $count));
        }
        $this->out("");
        $this->outAt("Total messages across folders: $total");
    }
    /**
     * Exports one of a user's local mail folders to an mbox file.
     * Streams the folder's messages in UID order, writing each as
     * an mbox entry: a "From " postmark line carrying the message's
     * IMAP internal date, then the message bytes with any body line
     * beginning "From " quoted to ">From " so it is not mistaken
     * for a separator, then a blank line. The message's stored IMAP
     * flags are written into Status and X-Status headers ahead of
     * the message so a later import recovers them, since flags live
     * in the folder index rather than the message bytes. The output
     * file is overwritten if it exists. Streaming one message at a
     * time keeps memory flat on a folder of tens of thousands of
     * messages.
     *
     * @param array $argv command line arguments
     * @return void
     */
    public function export($argv)
    {
        if (count($argv) < 5) {
            $this->out("Usage: php MailTool.php export <user> " .
                "<folder> <out_file>");
            return;
        }
        $user = $argv[2];
        $folder = $argv[3];
        $out_file = $argv[4];
        $storage = new FileMailStorage(C\MAIL_DIR);
        if (!is_dir(C\MAIL_DIR . "/users/" .
            $this->safeName($user))) {
            $this->out("ERROR: no mail directory for user $user");
            return;
        }
        if (!in_array($folder, $storage->listFolders($user))) {
            $this->out("ERROR: no folder \"$folder\" for user " .
                "$user");
            return;
        }
        $handle = fopen($out_file, "wb");
        if ($handle === false) {
            $this->out("ERROR: cannot open $out_file for writing");
            return;
        }
        $messages = $storage->listMessages($user, $folder);
        $written = 0;
        $expected = count($messages);
        foreach ($messages as $record) {
            $uid = $record['uid'];
            $bytes = $storage->fetchMessage($user, $folder, $uid);
            if ($bytes === false || $bytes === "") {
                continue;
            }
            fwrite($handle, $this->mboxEntry($bytes,
                $record['internal_date'], $record['flags']));
            $written++;
            if ($written % 1000 === 0) {
                $this->outAt(sprintf("  exported %d of %d ...",
                    $written, $expected));
            }
        }
        fclose($handle);
        $this->out("");
        $this->outAt("Exported $written message(s) from " .
            "\"$folder\" to $out_file");
    }
    /**
     * Builds one mbox entry for a message: the "From " postmark
     * line dated from the internal date, Status and X-Status flag
     * headers inserted at the top of the header block so an import
     * recovers the IMAP flags, the message body with "From "-
     * leading lines quoted to ">From ", and a trailing blank line
     * separating entries.
     *
     * @param string $bytes the raw message bytes
     * @param int $internal_date message IMAP internal date as a
     *      Unix timestamp, used for the postmark
     * @param array $flags IMAP flags stored for the message
     * @return string the mbox entry text including its separators
     */
    public function mboxEntry($bytes, $internal_date, $flags)
    {
        $postmark = "From MAILER-DAEMON " .
            date("D M d H:i:s Y", (int) $internal_date) . "\n";
        $with_flags = $this->insertFlagHeaders($bytes, $flags);
        $normalized = str_replace("\r\n", "\n", $with_flags);
        $lines = explode("\n", $normalized);
        $quoted = "";
        $last = count($lines) - 1;
        foreach ($lines as $position => $line) {
            if (preg_match('/^>*From /', $line)) {
                $line = ">" . $line;
            }
            $quoted .= $line;
            if ($position !== $last) {
                $quoted .= "\n";
            }
        }
        if (!str_ends_with($quoted, "\n")) {
            $quoted .= "\n";
        }
        return $postmark . $quoted . "\n";
    }
    /**
     * Inserts Status and X-Status headers carrying the message's
     * IMAP flags at the top of its header block, so an mbox import
     * recovers the flags (which MailSite stores in the folder index
     * rather than in the message bytes). Status R marks Seen;
     * X-Status letters A, F, D, T mark Answered, Flagged, Deleted,
     * Draft. When there are no flags to record the message is
     * returned unchanged.
     *
     * @param string $bytes the raw message bytes
     * @param array $flags IMAP flags stored for the message
     * @return string the message bytes with flag headers prepended
     */
    public function insertFlagHeaders($bytes, $flags)
    {
        $status = "";
        if (in_array(MailSite::FLAG_SEEN, $flags)) {
            $status = "R";
        }
        $x_status = "";
        $map = [
            MailSite::FLAG_ANSWERED => "A",
            MailSite::FLAG_FLAGGED => "F",
            MailSite::FLAG_DELETED => "D",
            MailSite::FLAG_DRAFT => "T",
        ];
        foreach ($map as $flag => $letter) {
            if (in_array($flag, $flags)) {
                $x_status .= $letter;
            }
        }
        if ($status === "" && $x_status === "") {
            return $bytes;
        }
        $headers = "";
        if ($status !== "") {
            $headers .= "Status: $status\r\n";
        }
        if ($x_status !== "") {
            $headers .= "X-Status: $x_status\r\n";
        }
        return $headers . $bytes;
    }
    public function eachMessage($source)
    {
        foreach (['cur', 'new'] as $sub) {
            $dir = $source . "/" . $sub;
            if (!is_dir($dir)) {
                continue;
            }
            $handle = @opendir($dir);
            if ($handle === false) {
                continue;
            }
            while (($entry = readdir($handle)) !== false) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $path = $dir . "/" . $entry;
                if (!is_file($path)) {
                    continue;
                }
                yield [
                    'path' => $path,
                    'flags' => $this->parseFlags($entry),
                    'internal_date' =>
                        $this->internalDate($entry, $path),
                ];
            }
            closedir($handle);
        }
    }
    /**
     * Counts the message files under a Maildir folder's cur/ and
     * new/ subdirectories without building any per-message data,
     * so the import can report a total cheaply before streaming.
     *
     * @param string $source path to the Maildir folder
     * @return int number of message files
     */
    public function countMessages($source)
    {
        $count = 0;
        foreach (['cur', 'new'] as $sub) {
            $dir = $source . "/" . $sub;
            if (!is_dir($dir)) {
                continue;
            }
            $handle = @opendir($dir);
            if ($handle === false) {
                continue;
            }
            while (($entry = readdir($handle)) !== false) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                if (is_file($dir . "/" . $entry)) {
                    $count++;
                }
            }
            closedir($handle);
        }
        return $count;
    }
    /**
     * Determines the IMAP internal date for a Maildir message,
     * preferring the filename delivery timestamp (it survives
     * copies and backups better than mtime), then mtime, then
     * the current time.
     *
     * @param string $filename the Maildir entry name
     * @param string $path full path to the message file
     * @return int unix timestamp to record as the internal date
     */
    public function internalDate($filename, $path)
    {
        $dot = strpos($filename, ".");
        if ($dot !== false) {
            $leading = substr($filename, 0, $dot);
            if (ctype_digit($leading) && $leading > 0) {
                return (int) $leading;
            }
        }
        $mtime = filemtime($path);
        if ($mtime !== false) {
            return $mtime;
        }
        return time();
    }
    /**
     * Parses the IMAP flags encoded in a Maildir cur/ filename.
     * The info portion follows ":2," and is a run of flag
     * letters; files in new/ carry no suffix and have no flags.
     *
     * @param string $filename the Maildir entry name
     * @return array list of MailSite flag strings
     */
    public function parseFlags($filename)
    {
        $marker = strpos($filename, ":2,");
        if ($marker === false) {
            return [];
        }
        $letters = substr($filename, $marker + 3);
        $flags = [];
        $length = strlen($letters);
        for ($i = 0; $i < $length; $i++) {
            $letter = $letters[$i];
            if (isset($this->maildir_flags[$letter])) {
                $flags[] = $this->maildir_flags[$letter];
            }
        }
        return $flags;
    }
    /**
     * Counts the messages in an mbox file by tallying the "From "
     * postmark lines that separate them, without holding the file
     * in memory, so the import can report a total before
     * streaming.
     *
     * @param string $source path to the mbox file
     * @return int number of messages
     */
    public function countMboxMessages($source)
    {
        if (!is_readable($source)) {
            return 0;
        }
        $handle = fopen($source, "r");
        if ($handle === false) {
            return 0;
        }
        $count = 0;
        while (($line = fgets($handle)) !== false) {
            if ($this->isMboxPostmark($line)) {
                $count++;
            }
        }
        fclose($handle);
        return $count;
    }
    /**
     * Streams an mbox file one message at a time. Messages are
     * split on the "From " postmark line that begins each entry;
     * the postmark itself is dropped, body lines that were quoted
     * to ">From " on write are unquoted, and flags and the
     * internal date are read from the message and the postmark.
     *
     * @param string $source path to the mbox file
     * @return \Generator yields ['key' => string, 'bytes' =>
     *      string, 'flags' => array, 'internal_date' => int]
     */
    public function eachMboxMessage($source)
    {
        if (!is_readable($source)) {
            return;
        }
        $handle = fopen($source, "r");
        if ($handle === false) {
            return;
        }
        $base = basename($source);
        $lines = [];
        $postmark = "";
        $ordinal = 0;
        $started = false;
        while (($line = fgets($handle)) !== false) {
            if ($this->isMboxPostmark($line)) {
                if ($started && !empty($lines)) {
                    $ordinal++;
                    yield $this->buildMboxMessage($lines, $postmark,
                        $base, $ordinal);
                    $lines = [];
                }
                $postmark = $line;
                $started = true;
                continue;
            }
            if (!$started) {
                $started = true;
            }
            $lines[] = $this->unquoteFromLine($line);
        }
        fclose($handle);
        if ($started && !empty($lines)) {
            $ordinal++;
            yield $this->buildMboxMessage($lines, $postmark, $base,
                $ordinal);
        }
    }
    /**
     * Whether an mbox line is the "From " postmark that separates
     * messages. Body lines that began with "From " were quoted to
     * ">From " on write, so an unquoted leading "From " marks a
     * message boundary.
     *
     * @param string $line raw line including its newline
     * @return bool true if the line is a message separator
     */
    public function isMboxPostmark($line)
    {
        return str_starts_with($line, "From ");
    }
    /**
     * Restores a body line that mbox quoting turned from "From "
     * into ">From " (or ">>From ", and so on) by dropping one
     * leading ">". Other lines are returned unchanged.
     *
     * @param string $line raw line including its newline
     * @return string the unquoted line
     */
    public function unquoteFromLine($line)
    {
        if (preg_match('/^>+From /', $line)) {
            return substr($line, 1);
        }
        return $line;
    }
    /**
     * Assembles one mbox message from its accumulated body lines.
     * The journal key is the message's Message-ID when present
     * (stable across re-runs) and otherwise the source file name
     * plus the ordinal (unique within and across mbox files
     * imported into the same folder).
     *
     * @param array $lines message lines in order, newlines kept
     * @param string $postmark the "From " line that preceded the
     *      message, used as a last-resort date source
     * @param string $base source file basename for the fallback key
     * @param int $ordinal 1-based position of the message in the file
     * @return array ['key', 'bytes', 'flags', 'internal_date']
     */
    public function buildMboxMessage($lines, $postmark, $base,
        $ordinal)
    {
        $bytes = implode("", $lines);
        $message_id = $this->mboxMessageId($bytes);
        $key = ($message_id !== "") ? "mid:$message_id" :
            "ord:$base:$ordinal";
        return [
            'key' => $key,
            'bytes' => $bytes,
            'flags' => $this->mboxFlags($bytes),
            'internal_date' => $this->mboxDate($bytes, $postmark),
        ];
    }
    /**
     * Returns a message's header block: the bytes up to the first
     * blank line, where the headers end, so header lookups never
     * match text in the body.
     *
     * @param string $bytes the full message bytes
     * @return string the header portion of the message
     */
    public function mboxHeaderBlock($bytes)
    {
        $lf = strpos($bytes, "\n\n");
        $crlf = strpos($bytes, "\r\n\r\n");
        if ($lf === false && $crlf === false) {
            return $bytes;
        }
        if ($lf === false) {
            return substr($bytes, 0, $crlf);
        }
        if ($crlf === false) {
            return substr($bytes, 0, $lf);
        }
        return substr($bytes, 0, min($lf, $crlf));
    }
    /**
     * Extracts the Message-ID header value from a message's header
     * block, or the empty string when there is none.
     *
     * @param string $bytes the full message bytes
     * @return string the Message-ID value, or "" if absent
     */
    public function mboxMessageId($bytes)
    {
        $header = $this->mboxHeaderBlock($bytes);
        if (preg_match('/^Message-ID:\s*(.+)$/im', $header, $found)) {
            return trim($found[1]);
        }
        return "";
    }
    /**
     * Maps the legacy Status and X-Status header letters some mail
     * programs write into mbox files to the IMAP system flags
     * MailSite stores: Status R is Seen; X-Status A, F, D, T are
     * Answered, Flagged, Deleted, Draft.
     *
     * @param string $bytes the full message bytes
     * @return array list of MailSite flag strings
     */
    public function mboxFlags($bytes)
    {
        $header = $this->mboxHeaderBlock($bytes);
        $flags = [];
        if (preg_match('/^Status:\s*([A-Za-z]+)/im', $header, $found)
            && strpos($found[1], "R") !== false) {
            $flags[] = MailSite::FLAG_SEEN;
        }
        if (preg_match('/^X-Status:\s*([A-Za-z]+)/im', $header,
            $found)) {
            $map = [
                'A' => MailSite::FLAG_ANSWERED,
                'F' => MailSite::FLAG_FLAGGED,
                'D' => MailSite::FLAG_DELETED,
                'T' => MailSite::FLAG_DRAFT,
            ];
            $letters = $found[1];
            $length = strlen($letters);
            for ($i = 0; $i < $length; $i++) {
                if (isset($map[$letters[$i]])) {
                    $flags[] = $map[$letters[$i]];
                }
            }
        }
        return $flags;
    }
    /**
     * Determines the IMAP internal date for an mbox message: the
     * Date header if it parses, then the date on the "From "
     * postmark, then the current time.
     *
     * @param string $bytes the full message bytes
     * @param string $postmark the "From " line preceding the message
     * @return int unix timestamp to record as the internal date
     */
    public function mboxDate($bytes, $postmark)
    {
        $header = $this->mboxHeaderBlock($bytes);
        if (preg_match('/^Date:\s*(.+)$/im', $header, $found)) {
            $stamp = strtotime(trim($found[1]));
            if ($stamp !== false) {
                return $stamp;
            }
        }
        $space = strpos($postmark, " ");
        if ($space !== false) {
            $rest = substr($postmark, $space + 1);
            $next = strpos($rest, " ");
            if ($next !== false) {
                $stamp = strtotime(trim(substr($rest, $next + 1)));
                if ($stamp !== false) {
                    return $stamp;
                }
            }
        }
        return time();
    }
    /**
     * Prints the import tally and any per-message failures.
     *
     * @param int $expected number of source message files found
     * @param int $copied number newly appended this run
     * @param int $skipped number already imported on a prior run
     * @param array $failures list of failure description strings
     * @return void
     */
    public function reportImport($expected, $copied, $skipped,
        $failures)
    {
        $failed = count($failures);
        $this->out("");
        $this->outAt("Import summary");
        $this->out("  expected: $expected");
        $this->out("  copied:   $copied");
        $this->out("  skipped:  $skipped (already imported)");
        $this->out("  failed:   $failed");
        if ($failed > 0) {
            $this->out("Failures:");
            foreach ($failures as $failure) {
                $this->out("  - $failure");
            }
        }
        if ($copied + $skipped === $expected && $failed === 0) {
            $this->out("OK: every source message is in the " .
                "folder.");
        } else {
            $this->out("WARNING: not every message was imported; " .
                "review the failures above before trusting the " .
                "import.");
        }
    }
    /**
     * Sanitizes a username into the on-disk directory basename
     * the same way FileMailStorage does, so the tool resolves the
     * same user directory the daemon serves.
     *
     * @param string $user account username
     * @return string filesystem-safe directory basename
     */
    public function safeName($user)
    {
        $user = (string) $user;
        $user = preg_replace('/[^A-Za-z0-9._-]/', '_', $user);
        $user = ltrim($user, '._');
        if ($user === "" || preg_match('/^[._]+$/', $user) ||
            str_contains($user, '..')) {
            $user = "_invalid_";
        }
        return $user;
    }
    /**
     * Prints usage text for all subcommands.
     *
     * @return void
     */
    public function usage()
    {
        $this->out("MailTool maintenance commands:");
        $this->out("  php MailTool.php import " .
            "<maildir_or_mbox_path> <user> <folder>");
        $this->out("      Import a Maildir directory or mbox file " .
            "into a folder,");
        $this->out("      preserving flags and dates; resumable " .
            "and idempotent.");
        $this->out("  php MailTool.php export <user> <folder> " .
            "<out_file>");
        $this->out("      Export a user's local mail folder to an " .
            "mbox file,");
        $this->out("      writing flags and internal dates so it " .
            "round-trips.");
        $this->out("  php MailTool.php verify <user>");
        $this->out("      Print the message count of every folder " .
            "for a user.");
    }
    /**
     * Writes a line to standard output.
     *
     * @param string $line text to print
     * @return void
     */
    public function out($line)
    {
        echo $line . "\n";
    }
    /**
     * Writes a line to standard output prefixed with the current
     * wall-clock time, so progress and milestone lines show how
     * long each step of a long-running command is taking.
     *
     * @param string $line text to print
     * @return void
     */
    public function outAt($line)
    {
        echo "[" . date("H:i:s") . "] " . $line . "\n";
    }
}
$tool = new MailTool();
$tool->run($argv);
X