/ src / library / mail / MailUnreadProbe.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\library\mail;

use seekquarry\yioop\configs as C;

/**
 * Computes the total number of unread messages across all of a
 * user's external IMAP mail accounts. Used to populate the badge
 * on the social-controls mail icon so the user sees at a glance
 * whether they have unread mail.
 *
 * The badge needs to render on most navigation pages, so this
 * helper is cache-first: a successful count is stored in the
 * session for MAIL_UNREAD_BADGE_TTL seconds; subsequent calls
 * within that window return the cached value without touching
 * the IMAP server. Mail-modifying actions (viewMessage,
 * bulkAction) invalidate the cache via invalidate() so a freshly
 * read or deleted message shows up immediately in the badge.
 *
 * Failures per account (offline server, expired credentials,
 * etc.) contribute zero to the total but do not abort the whole
 * computation -- the badge stays useful even if one of several
 * accounts is misconfigured.
 */
class MailUnreadProbe
{
    /**
     * Returns the cached or freshly-computed total unread count
     * across all of $user_id's configured IMAP mail accounts.
     * Returns 0 when the Mail activity is disabled or the user
     * has no accounts; never throws.
     *
     * @param int $user_id the logged-in user's id
     * @param callable $accounts_provider returns the user's mail
     *      accounts with passwords; called only on a cache miss, so
     *      no model lookup happens when the cached badge is still
     *      valid or mail is switched off
     * @return int total unread message count
     */
    public static function count($user_id, $accounts_provider)
    {
        $user_id = (int) $user_id;
        if ($user_id <= 0) {
            return 0;
        }
        $mail_mode = C\nsdefined('MAIL_MODE') ?
            C\p('MAIL_MODE') : 'disabled';
        if ($mail_mode === 'disabled') {
            return 0;
        }
        if (!in_array($mail_mode, ['external_mail', 'both'])) {
            return 0;
        }
        $cached = self::readCache($user_id);
        if ($cached !== null) {
            return $cached;
        }
        $total = self::compute($accounts_provider());
        self::writeCache($user_id, $total);
        return $total;
    }
    /**
     * Returns the unread badge count straight from the cache,
     * without ever opening an IMAP connection. The shared page
     * chrome shows this badge on every request, so it must never
     * block: a slow or unreachable external mail server would
     * otherwise freeze the single-process web server for every
     * visitor while one person's badge is computed. Returns 0 when
     * mail is off, when external mail is not in use, or when
     * nothing has been cached yet. The cache is filled by count()
     * from the Mail activity, which already talks to the mail
     * servers, so the badge catches up the next time the user opens
     * their mail rather than stalling the whole site here.
     *
     * @param int $user_id the logged-in user's id
     * @return int the last cached unread total, or 0 when none is
     *      cached
     */
    public static function cachedCount($user_id)
    {
        $user_id = (int) $user_id;
        if ($user_id <= 0) {
            return 0;
        }
        $mail_mode = C\nsdefined('MAIL_MODE') ?
            C\p('MAIL_MODE') : 'disabled';
        if (!in_array($mail_mode, ['external_mail', 'both'],
                true)) {
            return 0;
        }
        $cached = self::readCache($user_id);
        return $cached === null ? 0 : $cached;
    }
    /**
     * Drops the cached count for $user_id (or for every user,
     * when $user_id is omitted) so the next call to count()
     * recomputes from a fresh IMAP STATUS. Called by mail
     * actions that change read state -- viewing a message marks
     * it Seen, bulk-delete removes unread messages, etc.
     *
     * @param int|null $user_id the user whose cache to clear;
     *      null clears the whole cache (used when the mail
     *      session is being torn down)
     */
    public static function invalidate($user_id = null)
    {
        if ($user_id === null) {
            unset($_SESSION['MAIL_UNREAD']);
            return;
        }
        $user_id = (int) $user_id;
        if (isset($_SESSION['MAIL_UNREAD'][$user_id])) {
            unset($_SESSION['MAIL_UNREAD'][$user_id]);
        }
    }
    /**
     * Returns the cached count when it is still within the TTL
     * window, or null when the cache is empty or expired. The
     * stored shape is ['count' => int, 'expires' => unix_ts]
     * scoped per user-id.
     *
     * @param int $user_id the user whose cache to inspect
     * @return int|null the cached count, or null if no usable
     *      cache entry is present
     */
    protected static function readCache($user_id)
    {
        if (empty($_SESSION['MAIL_UNREAD'][$user_id])) {
            return null;
        }
        $entry = $_SESSION['MAIL_UNREAD'][$user_id];
        if (!isset($entry['count'], $entry['expires'])) {
            return null;
        }
        if ($entry['expires'] < time()) {
            unset($_SESSION['MAIL_UNREAD'][$user_id]);
            return null;
        }
        return (int) $entry['count'];
    }
    /**
     * Stores a freshly-computed count in the per-user cache slot
     * with an expiry time TTL seconds in the future.
     *
     * @param int $user_id the user the count is for
     * @param int $count the just-computed unread total
     */
    protected static function writeCache($user_id, $count)
    {
        $ttl = C\nsdefined('MAIL_UNREAD_BADGE_TTL') ?
            (int) C\MAIL_UNREAD_BADGE_TTL : 300;
        if (empty($_SESSION['MAIL_UNREAD'])) {
            $_SESSION['MAIL_UNREAD'] = [];
        }
        $_SESSION['MAIL_UNREAD'][$user_id] = [
            'count' => (int) $count,
            'expires' => time() + $ttl,
        ];
    }
    /**
     * Adds up the unread message counts for a set of mail accounts
     * that have already been loaded with their passwords. Each
     * account is probed over IMAP with a STATUS command; a failure
     * on one account contributes zero and is swallowed so a single
     * bad account never blocks the badge for the rest. The accounts
     * are passed in because a library class must not reach into a
     * model itself.
     *
     * @param array $accounts mail accounts with decrypted
     *      passwords, as returned by
     *      MailAccountModel::getAccountsWithPassword
     * @return int the summed unread count across the accounts
     */
    protected static function compute($accounts)
    {
        $total = 0;
        foreach ($accounts as $account) {
            try {
                $total += self::accountUnread($account);
            } catch (\Exception $exception) {
                /* per-account failures must not block the
                   overall badge -- skip and continue. */
                continue;
            }
        }
        return $total;
    }
    /**
     * Opens an IMAP connection for one account, logs in, runs
     * STATUS on the account's default folder asking for the
     * UNSEEN message-count, parses the response, and closes the
     * connection. Returns 0 on any error.
     *
     * @param array $account a MailAccount row with PASSWORD
     *      populated (as returned by
     *      MailAccountModel::getAccountWithPassword)
     * @return int unread count for this account's default
     *      folder, 0 if unavailable
     */
    protected static function accountUnread($account)
    {
        $host = $account['HOST'];
        $port = (int) $account['PORT'];
        $allow_self_signed = !empty($account['ALLOW_SELF_SIGNED']);
        try {
            if ($account['TLS_MODE'] === 'starttls') {
                $client = ImapClient::connectStartTls($host,
                    $port, 5, $allow_self_signed);
            } else if ($account['TLS_MODE'] === 'plain') {
                $client = new ImapClient($host, $port);
            } else {
                $client = ImapClient::connectImaps($host, $port,
                    5, $allow_self_signed);
            }
        } catch (\Exception $e) {
            return 0;
        }
        try {
            $login = $client->send('LOGIN "' .
                self::quote($account['USERNAME']) . '" "' .
                self::quote($account['PASSWORD']) . '"');
            if ($login['status'] !== 'OK') {
                $client->close();
                return 0;
            }
            $folder = ($account['DEFAULT_FOLDER'] ?? '') ?:
                'INBOX';
            $status = $client->send('STATUS "' .
                self::quote($folder) . '" (UNSEEN)');
            $client->close();
            if ($status['status'] !== 'OK') {
                return 0;
            }
            return self::parseUnseen($status['untagged']);
        } catch (\Exception $e) {
            return 0;
        }
    }
    /**
     * Picks the UNSEEN message-count out of the untagged STATUS
     * response. RFC 3501 section 7.2.4 shape:
     *   * STATUS "Folder" (UNSEEN 12 MESSAGES 100 ...)
     * Returns 0 when the field is missing from the response.
     *
     * @param array $untagged the untagged STATUS response lines
     * @return int the parsed UNSEEN value
     */
    protected static function parseUnseen($untagged)
    {
        foreach ($untagged as $line) {
            if (preg_match('/UNSEEN\s+(\d+)/i', $line, $m)) {
                return (int) $m[1];
            }
        }
        return 0;
    }
    /**
     * IMAP quoted-string escaping for command arguments. Doubles
     * any backslashes and double-quotes in the input so the
     * outer quote layer is unambiguous. The Mail activity also
     * has a userMailImapQuote helper in SocialComponent that
     * does the same thing for the inbox path; that helper is
     * not reachable from this library file without a circular
     * dependency, so the four-line implementation is duplicated
     * here.
     *
     * @param string $value the argument to quote
     * @return string the escaped value (without the surrounding
     *      double-quotes, which the caller adds)
     */
    protected static function quote($value)
    {
        return str_replace(['\\', '"'], ['\\\\', '\\"'],
            (string) $value);
    }
}
X