<?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);
}
}