/ tests / ManyEmailExperiment.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
 *
 * Injects synthetic mail into MailSite's storage for a target Yioop
 * user so the webmail UI can be exercised under realistic message
 * counts: scrolling, pagination, sort, filter, bulk select.
 *
 * @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\tests;

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

if (isset($_SERVER['DOCUMENT_ROOT']) &&
    strlen($_SERVER['DOCUMENT_ROOT']) > 0) {
    echo "BAD REQUEST";
    exit();
}
/**
 * Calculate parent directory of script
 * @ignore
 */
define("seekquarry\\yioop\\configs\\PARENT_DIR", substr(
    dirname(realpath($_SERVER['PHP_SELF'])), 0,
    -strlen("/tests")));
/**
 * Calculate base directory of script
 * @ignore
 */
define("seekquarry\\yioop\\configs\\BASE_DIR",
    C\PARENT_DIR . "/src");
require_once C\BASE_DIR . '/configs/Config.php';
require_once C\BASE_DIR . '/library/atto_servers/MailSite.php';
C\nsconddefine("ME_NUM_MESSAGES", 500);
C\nsconddefine("ME_FROM_POOL_N", 20);
C\nsconddefine("ME_SEEN_RATIO", 0.6);
C\nsconddefine("ME_FLAGGED_RATIO", 0.05);
C\nsconddefine("ME_DAYS_SPAN", 60);
C\nsconddefine("ME_ATTACHMENT_RATIO", 0.1);
C\nsconddefine("ME_FOLDERS_JSON",
    '["INBOX", "INBOX", "INBOX", "INBOX", "Sent", "Archive"]');
/**
 * Prints a usage / help message describing the required argument
 * and the configurable knobs, then exits. Called when the script
 * is run with no arguments or with -h / --help.
 */
function printHelp()
{
    $script = basename(__FILE__);
    echo "ManyEmailExperiment\n";
    echo "===================\n";
    echo "Injects synthetic mail into MailSite's storage for one\n";
    echo "Yioop user, so the webmail UI can be exercised under\n";
    echo "realistic message counts: scrolling, pagination, sort,\n";
    echo "filter, bulk select.\n\n";
    echo "USAGE\n";
    echo "  php $script <recipient_email>\n\n";
    echo "ARGUMENT\n";
    echo "  recipient_email   The address mail is delivered to,\n";
    echo "                    in <user>@<domain> form (e.g.\n";
    echo "                    'root@127.0.0.1', " .
        "'chris@example.com').\n";
    echo "                    <user> is the bare Yioop username\n";
    echo "                    of whoever will sign in to view\n";
    echo "                    the seeded mail. <domain> goes in\n";
    echo "                    the To: header on each synthetic\n";
    echo "                    message and should match one of\n";
    echo "                    the entries in your MAIL_DOMAINS\n";
    echo "                    Server-Settings list.\n\n";
    echo "KNOBS (override via nsconddefine before running, or\n";
    echo "  edit the constants near the top of this file)\n";
    echo "  ME_NUM_MESSAGES     " . C\ME_NUM_MESSAGES .
        " (messages to inject)\n";
    echo "  ME_FROM_POOL_N      " . C\ME_FROM_POOL_N .
        " (distinct senders to cycle)\n";
    echo "  ME_SEEN_RATIO       " . C\ME_SEEN_RATIO .
        " (fraction marked \\Seen)\n";
    echo "  ME_FLAGGED_RATIO    " . C\ME_FLAGGED_RATIO .
        " (fraction marked \\Flagged)\n";
    echo "  ME_DAYS_SPAN        " . C\ME_DAYS_SPAN .
        " (date spread, days back from now)\n";
    echo "  ME_ATTACHMENT_RATIO " . C\ME_ATTACHMENT_RATIO .
        " (fraction with a synthetic attachment)\n";
    echo "  ME_FOLDERS_JSON     JSON array of folder names to\n";
    echo "                      distribute messages across.\n";
    echo "                      Default cycles 4 INBOX, 1 Sent,\n";
    echo "                      1 Archive so most mail lands\n";
    echo "                      where folder UI work is exercised.\n\n";
    echo "RE-RUNS\n";
    echo "  Each run appends fresh messages with new UIDs. To start\n";
    echo "  clean delete WORK_DIRECTORY/mail/<user>/ between runs.\n";
}
$args = $_SERVER['argv'] ?? [];
$recipient = $args[1] ?? '';
if ($recipient === '' || in_array($recipient,
        ['-h', '--help'], true)) {
    printHelp();
    exit(0);
}
$at_pos = strpos($recipient, '@');
if ($at_pos === false || $at_pos === 0 ||
    $at_pos === strlen($recipient) - 1) {
    echo "ERROR: recipient_email must be in <user>@<domain> " .
        "form; got: $recipient\n\n";
    printHelp();
    exit(1);
}
$user = substr($recipient, 0, $at_pos);
/**
 * Builds a Lorem-ipsum-style synthetic body of roughly $words
 * words. Output is deterministic given the same $seed so a
 * second run with the same inputs produces the same corpus.
 *
 * @param int $words approximate word count
 * @param int $seed PRNG seed for reproducible output
 * @return string the synthetic body, terminated with CRLF
 */
function syntheticBody($words, $seed)
{
    $vocab = ['lorem', 'ipsum', 'dolor', 'sit', 'amet',
        'consectetur', 'adipiscing', 'elit', 'sed', 'do',
        'eiusmod', 'tempor', 'incididunt', 'ut', 'labore',
        'magna', 'aliqua', 'enim', 'ad', 'minim', 'veniam',
        'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris',
        'nisi', 'aliquip', 'ex', 'ea', 'commodo', 'consequat',
        'duis', 'aute', 'irure', 'reprehenderit', 'voluptate',
        'velit', 'esse', 'cillum', 'fugiat', 'nulla', 'pariatur',
        'excepteur', 'sint', 'occaecat', 'cupidatat', 'non',
        'proident', 'sunt', 'culpa', 'qui', 'officia', 'deserunt',
        'mollit', 'anim', 'id', 'est', 'laborum'];
    $count = count($vocab);
    mt_srand($seed);
    $output = '';
    $line_words = 0;
    for ($word_index = 0; $word_index < $words; $word_index++) {
        $word = $vocab[mt_rand(0, $count - 1)];
        if ($line_words === 0) {
            $output .= ucfirst($word);
        } else {
            $output .= ' ' . $word;
        }
        $line_words++;
        if ($line_words >= 8 + ($word_index % 5)) {
            $output .= ".\r\n";
            $line_words = 0;
        }
    }
    if ($line_words > 0) {
        $output .= ".\r\n";
    }
    return $output;
}
/**
 * Returns one synthetic sender address from a deterministic
 * pool of size ME_FROM_POOL_N so distinct senders cycle
 * predictably across the run.
 *
 * @param int $sender_index modular index into the pool
 * @return string the address in "Display Name <user@host>" form
 */
function senderAddress($sender_index)
{
    $first_names = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve',
        'Frank', 'Grace', 'Heidi', 'Ivan', 'Judy', 'Karl',
        'Liam', 'Mia', 'Niko', 'Olga', 'Paul', 'Quinn',
        'Rita', 'Sam', 'Tara', 'Uma', 'Vera', 'Will', 'Xena',
        'Yara', 'Zack'];
    $last_names = ['Adams', 'Brown', 'Clark', 'Davis', 'Evans',
        'Fox', 'Green', 'Hill', 'Irwin', 'Jones', 'King',
        'Lewis', 'Moore', 'Nash', 'Owens', 'Perez', 'Quinn',
        'Rios', 'Singh', 'Tan', 'Underwood', 'Vance', 'Wong',
        'Xu', 'Young', 'Zhao'];
    $first = $first_names[$sender_index % count($first_names)];
    $last = $last_names[($sender_index * 7) % count($last_names)];
    $domain_pool = ['example.com', 'test.org', 'demo.net',
        'sample.io', 'mailtest.dev'];
    $domain = $domain_pool[$sender_index % count($domain_pool)];
    $local = strtolower($first) . '.' .
        strtolower($last) . ($sender_index % 100);
    return "$first $last <$local@$domain>";
}
/**
 * Builds one synthetic RFC 5322 message: headers + body, with
 * an optional inline attachment when the random gate hits.
 * Deterministic given $message_index so reruns produce
 * identical bytes.
 *
 * @param int $message_index 0-based position in the corpus
 * @param string $recipient destination Yioop user (the
 *      To: header value; not the resolved local mailbox)
 * @param int $when unix timestamp the message claims to have
 *      been sent at
 * @param bool $include_attachment whether to emit a
 *      multipart/mixed message with a small text attachment
 * @return string raw RFC 5322 bytes
 */
function buildMessage($message_index, $recipient, $when,
    $include_attachment)
{
    $from = senderAddress($message_index % C\ME_FROM_POOL_N);
    $subjects = [
        'Quick question about',
        'Re: notes from',
        'Heads up on',
        'Following up on',
        'FW: review of',
        'Latest update on',
        'Status of',
        'Reminder:',
        'Action needed:',
        'For your review:'];
    $topics = ['Q4 planning', 'the deployment', 'next week',
        'the proposal', 'the design doc', 'the migration',
        'the rollout', 'the customer call', 'the budget',
        'the integration'];
    $subject = $subjects[$message_index % count($subjects)] .
        ' ' . $topics[($message_index * 3) % count($topics)] .
        ' #' . $message_index;
    $message_id = '<msg-' . $message_index . '-' . $when .
        '@yioop-test>';
    $date_header = date('r', $when);
    $body = syntheticBody(40 + ($message_index % 60),
        $message_index + 1);
    $headers = "From: $from\r\n" .
        "To: $recipient\r\n" .
        "Subject: $subject\r\n" .
        "Date: $date_header\r\n" .
        "Message-ID: $message_id\r\n" .
        "MIME-Version: 1.0\r\n";
    if (!$include_attachment) {
        return $headers .
            "Content-Type: text/plain; charset=UTF-8\r\n" .
            "\r\n" . $body;
    }
    $boundary = '=_meexp_' . sprintf('%08x', $message_index);
    $attachment_name = 'note-' . $message_index . '.txt';
    $attachment_body =
        "Synthetic attachment for message $message_index\r\n" .
        "Sent at $date_header\r\n" .
        "(part of ManyEmailExperiment)\r\n";
    $encoded = chunk_split(base64_encode($attachment_body), 76,
        "\r\n");
    return $headers .
        "Content-Type: multipart/mixed; " .
        "boundary=\"$boundary\"\r\n" .
        "\r\n" .
        "This is a multi-part message in MIME format.\r\n" .
        "--$boundary\r\n" .
        "Content-Type: text/plain; charset=UTF-8\r\n" .
        "Content-Transfer-Encoding: 7bit\r\n" .
        "\r\n" .
        $body .
        "\r\n" .
        "--$boundary\r\n" .
        "Content-Type: text/plain; charset=UTF-8; " .
        "name=\"$attachment_name\"\r\n" .
        "Content-Transfer-Encoding: base64\r\n" .
        "Content-Disposition: attachment; " .
        "filename=\"$attachment_name\"\r\n" .
        "\r\n" .
        $encoded .
        "\r\n--$boundary--\r\n";
}

$folders = json_decode(C\ME_FOLDERS_JSON, true);
if (!is_array($folders) || empty($folders)) {
    echo "ME_FOLDERS_JSON did not decode to a non-empty array\n";
    exit(1);
}
$num = (int) C\ME_NUM_MESSAGES;
$storage_path = C\WORK_DIRECTORY . DIRECTORY_SEPARATOR . 'mail';
echo "ManyEmailExperiment\n";
echo "===================\n";
echo "  storage path : $storage_path\n";
echo "  storage user : $user\n";
echo "  recipient    : $recipient\n";
echo "  messages     : $num\n";
echo "  folders      : " . implode(', ', $folders) . "\n";
echo "  days span    : " . C\ME_DAYS_SPAN . "\n";
echo "  seen ratio   : " . C\ME_SEEN_RATIO . "\n";
echo "  flagged      : " . C\ME_FLAGGED_RATIO . "\n";
echo "  attachments  : " . C\ME_ATTACHMENT_RATIO . "\n";
echo "\n";
/* preflight permission check: FileMailStorage suppresses
   filesystem errors with @ so a silent "appendMessage failed
   at index 0" gives the operator no clue. Probe the storage
   tree and the per-user subdirectory now and bail with a
   useful message if anything is unwritable. */
$probe_paths = [$storage_path];
$user_dir = $storage_path . DIRECTORY_SEPARATOR . 'users' .
    DIRECTORY_SEPARATOR . $user;
if (is_dir($user_dir)) {
    $probe_paths[] = $user_dir;
}
foreach ($probe_paths as $probe) {
    if (!is_dir($probe)) {
        continue;
    }
    if (is_writable($probe)) {
        continue;
    }
    $owner_uid = @fileowner($probe);
    $owner_name = function_exists('posix_getpwuid') ?
        (posix_getpwuid($owner_uid)['name'] ?? "uid:$owner_uid") :
        "uid:$owner_uid";
    $self_uid = function_exists('posix_geteuid') ?
        posix_geteuid() : (int) @get_current_user();
    $self_name = function_exists('posix_getpwuid') ?
        (posix_getpwuid($self_uid)['name'] ?? "uid:$self_uid") :
        (string) $self_uid;
    echo "ERROR: $probe is not writable.\n";
    echo "  owned by    : $owner_name\n";
    echo "  running as  : $self_name\n";
    echo "  Hint: if the Yioop server runs as $owner_name " .
        "(e.g. you started it with sudo),\n";
    echo "  re-run this experiment as the same user:\n";
    echo "    sudo php " . basename(__FILE__) . " $recipient\n";
    exit(1);
}
$storage = new FileMailStorage($storage_path);
if (!$storage->ensureUser($user)) {
    echo "ERROR: ensureUser($user) failed. Inspect " .
        "$user_dir manually.\n";
    exit(1);
}
$now = time();
$span_seconds = ((int) C\ME_DAYS_SPAN) * 86400;
mt_srand(20260523);
for ($index = 0; $index < $num; $index++) {
    $folder = $folders[$index % count($folders)];
    /* spread messages roughly uniformly across the span,
       newer messages going to the end of the iteration so the
       inbox-newest-first view shows the last few injected
       messages on top. */
    $internal_date = $now - $span_seconds +
        (int) ($index * $span_seconds / max(1, $num));
    $include_attachment =
        (mt_rand(1, 100) / 100.0) < C\ME_ATTACHMENT_RATIO;
    $bytes = buildMessage($index, $recipient,
        $internal_date, $include_attachment);
    $flags = [];
    if ((mt_rand(1, 100) / 100.0) < C\ME_SEEN_RATIO) {
        $flags[] = '\\Seen';
    }
    if ((mt_rand(1, 100) / 100.0) < C\ME_FLAGGED_RATIO) {
        $flags[] = '\\Flagged';
    }
    $uid = $storage->appendMessage($user, $folder, $bytes, $flags,
        $internal_date);
    if ($uid === false) {
        $folder_dir = $storage_path . DIRECTORY_SEPARATOR .
            'users' . DIRECTORY_SEPARATOR . $user .
            DIRECTORY_SEPARATOR . $folder;
        $writable = is_writable($folder_dir) ? 'writable' :
            'NOT writable';
        echo "ERROR: appendMessage failed at index $index, " .
            "folder=$folder.\n";
        echo "  Target dir : $folder_dir ($writable)\n";
        if (function_exists('posix_geteuid')) {
            echo "  Running as : uid " . posix_geteuid() . "\n";
        }
        exit(1);
    }
    if (($index + 1) % 25 === 0 || $index + 1 === $num) {
        echo "  injected " . ($index + 1) . " / $num " .
            "(latest UID $uid in $folder)\n";
    }
}
echo "\nDone. Sign in to Yioop as \"$user\" and open the Mail\n";
echo "activity; the new messages should appear under MailSite's\n";
echo "INBOX/Sent/Archive folders, with attachments where flagged.\n";
X