<?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\tests;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\UnsubscribeToken;
use seekquarry\yioop\library\media_jobs\BulkEmailJob;
/**
* Tests that BulkEmailJob holds back list mail to an address that has
* unsubscribed from everything, while still sending transactional mail
* such as a password reset to that same address.
*
* @author Chris Pollett
*/
class BulkEmailJobTest extends UnitTest
{
/**
* The job under test, with stub models and store injected
* @var object
*/
public $job;
/**
* Stub suppression list; records addresses passed to suppress()
* @var object
*/
public $suppression;
/**
* Stub group model; records setMailSubscription() calls
* @var object
*/
public $group;
/**
* Stub mail store standing in for the bot mailbox
* @var object
*/
public $storage;
/**
* Builds the job and replaces its suppression list, group model and
* mail store with recording stubs.
*/
public function setUp()
{
$this->job = new BulkEmailJob();
$this->suppression = new class {
/**
* Addresses passed to suppress().
* @var array
*/
public $suppressed = [];
/**
* One fixed address counts as already suppressed.
*
* @param string $email address to check
* @return bool true when it is the blocked address
*/
public function isSuppressed($email)
{
return strtolower(trim((string) $email)) ===
"blocked@example.com";
}
/**
* Records a whole-site suppression request.
*
* @param string $email address to suppress
*/
public function suppress($email)
{
$this->suppressed[] = $email;
}
};
$this->job->suppression_model = $this->suppression;
$this->group = new class {
/**
* Records of [user_id, group_id, receive].
* @var array
*/
public $subscriptions = [];
/**
* Records a change to a member's group-mail setting.
*
* @param int $user_id the member
* @param int $group_id the group
* @param int $receive 1 to receive, 0 to stop
*/
public function setMailSubscription($user_id, $group_id,
$receive)
{
$this->subscriptions[] =
[$user_id, $group_id, $receive];
}
};
$this->job->group_model = $this->group;
$this->storage = new class {
/**
* Message records the stub mailbox returns.
* @var array
*/
public $messages = [];
/**
* Header bytes keyed by uid.
* @var array
*/
public $headers = [];
/**
* Uids that setFlags() was called for.
* @var array
*/
public $flagged = [];
/**
* Whether expunge() was called.
* @var bool
*/
public $expunged = false;
/**
* Count of messageHeaderBytes() calls, used to confirm that
* only the search candidates are opened, not the whole
* mailbox.
* @var int
*/
public $header_reads = 0;
/**
* The bot mailbox always exists in the stub.
*
* @param string $user mailbox user
* @param string $folder folder name
* @return bool true
*/
public function folderExists($user, $folder)
{
return true;
}
/**
* Returns the stub's message records.
*
* @param string $user mailbox user
* @param string $folder folder name
* @return array message records
*/
public function listMessages($user, $folder)
{
return $this->messages;
}
/**
* Mirrors the real search index: returns the uids that are
* present in the mailbox and whose stub header bytes contain
* the query word. The real store matches on Subject, From,
* and To only; the stub matches the whole header block, which
* is sufficient for these messages.
*
* @param string $user mailbox user
* @param string $folder folder name
* @param string $query word to look for
* @return array list of matching uids
*/
public function searchMessages($user, $folder, $query)
{
$needle = strtolower(trim((string)$query));
$present = [];
foreach ($this->messages as $message) {
$present[(int)$message['uid']] = true;
}
$hits = [];
foreach ($this->headers as $uid => $header_bytes) {
if (!isset($present[(int)$uid])) {
continue;
}
if (strpos(strtolower((string)$header_bytes),
$needle) !== false) {
$hits[] = (int)$uid;
}
}
return $hits;
}
/**
* Returns the stub header bytes for a uid.
*
* @param string $user mailbox user
* @param string $folder folder name
* @param int $uid message uid
* @return string header bytes
*/
public function messageHeaderBytes($user, $folder, $uid)
{
$this->header_reads++;
return $this->headers[$uid] ?? "";
}
/**
* Records that a message was flagged.
*
* @param string $user mailbox user
* @param string $folder folder name
* @param int $uid message uid
* @param array $flags flags to set
*/
public function setFlags($user, $folder, $uid, $flags)
{
$this->flagged[] = $uid;
}
/**
* Records that expunge ran.
*
* @param string $user mailbox user
* @param string $folder folder name
* @param mixed $uid_restriction optional uid limit
*/
public function expunge($user, $folder,
$uid_restriction = null)
{
$this->expunged = true;
}
};
$this->job->mail_storage = $this->storage;
}
/**
* No teardown needed.
*/
public function tearDown()
{
}
/**
* List mail (carries List-Unsubscribe) addressed to a suppressed
* recipient is held back.
*/
public function listMailToSuppressedHeldTestCase()
{
$email = ["Group news", "from@example.com", "blocked@example.com",
"body", ["List-Unsubscribe" => "<https://example.com/u>"]];
$this->assertTrue($this->job->suppressedRecipient($email),
"list mail to a suppressed address is held back");
}
/**
* List mail to an address that is not suppressed is sent.
*/
public function listMailToOkAddressSentTestCase()
{
$email = ["Group news", "from@example.com", "ok@example.com",
"body", ["List-Unsubscribe" => "<https://example.com/u>"]];
$this->assertFalse($this->job->suppressedRecipient($email),
"list mail to a normal address is sent");
}
/**
* Transactional mail with no List-Unsubscribe (a password reset) is
* sent even to a suppressed address, so the recipient can still
* recover their account.
*/
public function passwordResetToSuppressedSentTestCase()
{
$email = ["Password reset", "from@example.com",
"blocked@example.com", "body"];
$this->assertFalse($this->job->suppressedRecipient($email),
"a password reset to a suppressed address is still sent");
}
/**
* A valid member-and-group token turns off that group's mail for
* that member.
*/
public function groupTokenUnsubscribesGroupTestCase()
{
$token = UnsubscribeToken::make(42, 7);
$acted = $this->job->applyUnsubscribeToken($token);
$this->assertTrue($acted, "a valid group token is acted on");
$this->assertEqual([[42, 7, 0]], $this->group->subscriptions,
"the member's mail for that group is turned off");
}
/**
* A valid whole-site token adds the address to the suppression list.
*/
public function emailTokenSuppressesTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$acted = $this->job->applyUnsubscribeToken($token);
$this->assertTrue($acted, "a valid whole-site token is acted on");
$this->assertEqual(["person@example.com"],
$this->suppression->suppressed,
"the address is suppressed site-wide");
}
/**
* A token that does not verify is ignored: nothing is unsubscribed
* or suppressed.
*/
public function forgedTokenIgnoredTestCase()
{
$acted = $this->job->applyUnsubscribeToken("not.a.realtoken");
$this->assertFalse($acted, "an unverifiable token is ignored");
$this->assertEqual(0, count($this->group->subscriptions),
"no group was touched");
$this->assertEqual(0, count($this->suppression->suppressed),
"no address was suppressed");
}
/**
* Reading the root INBOX acts on an unsubscribe message addressed
* to the bot and then removes it.
*/
public function botMailboxProcessesAndClearsTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$bot = MailSiteFactory::botAddress();
$this->storage->messages = [['uid' => 1]];
$this->storage->headers[1] = "Delivered-To: " . $bot .
"\r\nSubject: unsubscribe " . $token . "\r\n\r\n";
$this->job->processBotMailbox();
$this->assertEqual(["person@example.com"],
$this->suppression->suppressed,
"the address named by the message is suppressed");
$this->assertEqual([1], $this->storage->flagged,
"the processed message is flagged for deletion");
$this->assertTrue($this->storage->expunged,
"the mailbox is expunged afterwards");
}
/**
* Mail in the root INBOX that is not an unsubscribe addressed to
* the bot is left in place: a non-unsubscribe message and an
* unsubscribe message addressed elsewhere are both untouched, so
* nothing is suppressed, flagged, or expunged.
*/
public function botMailboxLeavesStrayMailTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$this->storage->messages = [['uid' => 5], ['uid' => 6]];
$this->storage->headers[5] = "Subject: hello there\r\n\r\n";
$this->storage->headers[6] = "Delivered-To: someone@other.test" .
"\r\nSubject: unsubscribe " . $token . "\r\n\r\n";
$this->job->processBotMailbox();
$this->assertEqual(0, count($this->suppression->suppressed),
"stray mail suppresses nothing");
$this->assertEqual([], $this->storage->flagged,
"stray mail is left in place");
$this->assertFalse($this->storage->expunged,
"the mailbox is not expunged for stray mail");
}
/**
* A mailbox full of unrelated mail with a single bot unsubscribe in
* it opens only that one message: the search index supplies the lone
* candidate, so the header reader runs once rather than once per
* message. This is the fix for the media-updater stall on a large
* personal root INBOX.
*/
public function botMailboxOpensOnlyCandidatesTestCase()
{
$token = UnsubscribeToken::makeEmail("person@example.com");
$bot = MailSiteFactory::botAddress();
$this->storage->messages = [['uid' => 1], ['uid' => 2],
['uid' => 3], ['uid' => 4]];
$this->storage->headers[1] = "Subject: lunch tomorrow\r\n\r\n";
$this->storage->headers[2] = "Subject: meeting notes\r\n\r\n";
$this->storage->headers[3] = "Delivered-To: " . $bot .
"\r\nSubject: unsubscribe " . $token . "\r\n\r\n";
$this->storage->headers[4] = "From: news@example.test\r\n" .
"Subject: weekly digest\r\n\r\n";
$this->job->processBotMailbox();
$this->assertEqual(1, $this->storage->header_reads,
"only the one search candidate is opened, not every message");
$this->assertEqual(["person@example.com"],
$this->suppression->suppressed,
"the bot unsubscribe is still acted on");
$this->assertEqual([3], $this->storage->flagged,
"the bot unsubscribe is flagged for deletion");
}
}