<?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";