/ tests / BulkEmailJobTest.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
 *
 * @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");
    }
}
X