<?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\controllers\components\SocialComponent;
/**
* MailBackend implementation that talks directly to atto's
* in-process MailSite via the MailStorage interface. Used for
* the synthetic per-Yioop-user mailbox: the account has no DB
* row, no remote host, no credentials; identity is implicit by
* Yioop username.
*
* The MailSite handle is built lazily on first use and cached
* for the lifetime of the backend instance. Hot paths such as
* MailCloneJob's per-message appendMessage loop would otherwise
* pay a factory construction (new MailSite + new
* FileMailStorage + domain enumeration) on every call; the
* cache turns that into a one-shot startup cost per backend.
*
* @author Chris Pollett
*/
class MailSiteMailBackend extends MailBackend
{
/**
* @var string Yioop username this backend is bound to; used
* as the mailbox key in every storage call
*/
protected $username;
/**
* Cached MailSite instance. Built on first access via
* mailSite() and reused for the lifetime of this backend.
* Null until the first call.
* @var \seekquarry\yioop\library\atto_servers\MailSite|null
*/
protected $cached_site = null;
/**
* @param string $username Yioop username (no @domain), used
* as the MailSite mailbox identifier
*/
public function __construct($username)
{
$this->username = $username;
}
/**
* Returns the cached MailSite handle, building it on first
* call. Every internal storage operation goes through this
* accessor instead of calling MailSiteFactory::build()
* directly, so the factory cost (constructor + storage
* init + domain enumeration) is paid once per backend
* instead of once per operation.
*
* @return object MailSite handle
*/
protected function mailSite()
{
if ($this->cached_site === null) {
$this->cached_site = MailSiteFactory::build();
}
return $this->cached_site;
}
/**
* Static catalog of the standard folders shown for every
* MailSite mailbox: INBOX plus the three IMAP-spec
* special-use folders. Returned in the ImapFolderListParser
* shape so renderFolderTree consumes the IMAP and MailSite
* lists identically.
*
* @return array list of folder rows
*/
public static function defaultFolders()
{
return [
['NAME' => 'INBOX', 'SELECTABLE' => true,
'SPECIAL_USE' => '', 'DELIMITER' => '/'],
['NAME' => 'Sent', 'SELECTABLE' => true,
'SPECIAL_USE' => '\\Sent', 'DELIMITER' => '/'],
['NAME' => 'Drafts', 'SELECTABLE' => true,
'SPECIAL_USE' => '\\Drafts', 'DELIMITER' => '/'],
['NAME' => 'Junk', 'SELECTABLE' => true,
'SPECIAL_USE' => '\\Junk', 'DELIMITER' => '/'],
['NAME' => 'Trash', 'SELECTABLE' => true,
'SPECIAL_USE' => '\\Trash', 'DELIMITER' => '/'],
];
}
/**
* {@inheritdoc}
*/
public function accountId()
{
return SocialComponent::MAILSITE_ACCOUNT_ID;
}
/**
* {@inheritdoc}
*/
public function displayName()
{
return $this->username;
}
/**
* {@inheritdoc}
*/
public function senderEmail()
{
$domains = MailSiteFactory::localDomains();
$primary = isset($domains[0]) ? $domains[0] : 'localhost';
return $this->username . '@' . $primary;
}
/**
* {@inheritdoc}
*/
public function isSyntheticAccount()
{
return true;
}
/**
* The synthetic per-user mailbox always opens to its inbox.
*
* @return string the standard inbox folder name
*/
public function defaultFolder()
{
return "INBOX";
}
/**
* {@inheritdoc}
*/
public function listFolders()
{
$defaults = self::defaultFolders();
$default_by_name = [];
foreach ($defaults as $row) {
$default_by_name[strtolower($row['NAME'])] = $row;
}
$site = $this->mailSite();
$storage_names = $site->listFolders($this->username);
$folders = [];
$seen_lower = [];
foreach ($storage_names as $name) {
$key = strtolower($name);
if (isset($default_by_name[$key])) {
/* preserve canonical default casing (INBOX) and
the SPECIAL_USE attribute. */
$folders[] = $default_by_name[$key];
} else {
$folders[] = ['NAME' => $name,
'SELECTABLE' => true, 'SPECIAL_USE' => '',
'DELIMITER' => '/'];
}
$seen_lower[$key] = true;
}
/* include any defaults that don't yet exist in storage
so the sidebar always shows the standard folder set. */
foreach ($defaults as $row) {
$key = strtolower($row['NAME']);
if (!isset($seen_lower[$key])) {
$folders[] = $row;
}
}
return ImapFolderListParser::sortBySpecialUse($folders);
}
/**
* {@inheritdoc}
*/
public function annotateUnreadCounts(&$folders)
{
$this->walkUnread($folders);
}
/**
* Recursive worker for annotateUnreadCounts. Walks each
* folder (and any CHILDREN entries) and attaches UNREAD
* to each selectable node by calling messageCount with the
* unread_only flag.
*
* @param array &$folders folder rows to annotate in place
*/
protected function walkUnread(&$folders)
{
foreach ($folders as &$folder) {
if (!empty($folder['CHILDREN'])) {
$this->walkUnread($folder['CHILDREN']);
}
if (empty($folder['SELECTABLE'])) {
continue;
}
try {
$folder['UNREAD'] = $this->messageCount(
$folder['NAME'], true);
} catch (MailBackendException $exception) {
/* per-folder failures are silent: the folder
simply does not get an UNREAD key and the
sidebar renders without a badge. */
}
}
}
/**
* {@inheritdoc}
*/
public function createFolder($name)
{
$site = $this->mailSite();
$ok = $site->createFolder($this->username, $name);
if (!$ok) {
throw new MailBackendException(tl(
'mail_backend_create_folder_failed', $name));
}
}
/**
* {@inheritdoc}
*/
public function renameFolder($from, $to)
{
$site = $this->mailSite();
$ok = $site->renameFolder($this->username, $from, $to);
if (!$ok) {
throw new MailBackendException(tl(
'mail_backend_rename_folder_failed', $from));
}
}
/**
* {@inheritdoc}
*/
public function deleteFolder($name)
{
$site = $this->mailSite();
$ok = $site->deleteFolder($this->username, $name);
if (!$ok) {
throw new MailBackendException(tl(
'mail_backend_delete_folder_failed', $name));
}
}
/**
* {@inheritdoc}
*
* The optional $sort spec orders the full record set before the
* window is taken, so a date-ascending request returns the
* oldest messages rather than re-ordering only the newest
* window. Only the date key is ordered here (its timestamp is
* on the raw index record); subject and from need per-message
* header fetches and so are read only for the messages that fall
* on the returned window, not the whole folder. A null $sort
* keeps the historical newest-first default.
*
* @param array|null $sort ["key" => string, "reverse" => bool]
* or null for the default newest-first order
*/
public function listMessages($folder, $request)
{
$window = (int) ($request['window'] ?? 25);
$sort = $request['sort'] ??
['key' => 'date', 'reverse' => false];
$filter = (string) ($request['filter'] ?? '');
$unread_only = !empty($request['unread_only']);
$flagged_only = !empty($request['flagged_only']);
$cursor = $request['cursor'] ?? null;
$site = $this->mailSite();
$records = $site->listMessages($this->username, $folder);
$total = count($records);
/* MailSite can only order by date. "reverse" on date means
oldest first; anything else is treated as the default
newest first. */
$date_ascending = !empty($sort['reverse']) &&
($sort['key'] ?? 'date') === 'date';
usort($records, function ($first, $second) use
($date_ascending) {
$first_date = (int) ($first['internal_date'] ?? 0);
$second_date = (int) ($second['internal_date'] ?? 0);
return $date_ascending ? ($first_date <=> $second_date) :
($second_date <=> $first_date);
});
if ($filter !== '') {
$matching = array_flip($this->search($folder, $filter));
$records = array_values(array_filter($records,
function ($record) use ($matching) {
return isset($matching[(int)
($record['uid'] ?? 0)]);
}));
}
/* Page on the lightweight index records first and read
message files only for the handful that land on the
window. Each record already carries the uid, date and
flags, so the date sort, the unread/flagged filters and
the windowing all run without opening a single message
file. Shaping every record up front instead would read
one header block per message on every window request: a
tens-of-thousands-message folder then does tens of
thousands of small reads per page, which an SSD hides but
a spinning disk turns into minutes of seeks that stall the
single-process web server for the whole listing. */
$candidates = [];
foreach ($records as $record) {
$uid = (int) ($record['uid'] ?? 0);
if ($uid < 1) {
continue;
}
$flags = $record['flags'] ?? [];
if ($unread_only && in_array('\\Seen', $flags, true)) {
continue;
}
if ($flagged_only &&
!in_array('\\Flagged', $flags, true)) {
continue;
}
$candidates[] = ['UID' => $uid, 'RECORD' => $record];
}
$effective_sort = ($sort['key'] ?? 'date') === 'date' ?
$sort : ['key' => 'date', 'reverse' => false];
if ($date_ascending) {
$result = $this->offsetWindow($candidates, $window,
$cursor, $total, $effective_sort);
} else {
$result = $this->rangeWindow($candidates, $window,
$cursor, $total, $effective_sort);
}
$shaped = [];
foreach ($result['messages'] as $candidate) {
$row = $this->shapeMessageRow($site, $folder,
$candidate['RECORD']);
if ($row !== null) {
$shaped[] = $row;
}
}
$result['messages'] = $shaped;
return $result;
}
/**
* Returns one window of already-ordered rows in the default
* newest-first view, paged by UID: the cursor names the oldest
* UID already shown and this returns the rows below it.
*
* @param array $rows the ordered, filtered rows (newest first)
* @param int $window how many to return
* @param array|null $cursor the previous window's cursor, or null
* @param int $total the folder's full message count
* @param array $sort the sort in effect
* @return array the listing result
*/
protected function rangeWindow($rows, $window, $cursor, $total,
$sort)
{
$before = $cursor ? (int) ($cursor['before'] ?? 0) : 0;
if ($before > 0) {
$rows = array_values(array_filter($rows,
function ($row) use ($before) {
return (int) ($row['UID'] ?? 0) < $before;
}));
}
$has_more = count($rows) > $window;
$page = array_slice($rows, 0, $window);
$oldest = empty($page) ? 0 :
(int) ($page[count($page) - 1]['UID'] ?? 0);
return ['messages' => $page, 'total' => $total,
'sort' => $sort, 'sort_supported' => false,
'sort_oversized' => false, 'sort_date_only' => true,
'mode' => 'range', 'has_more' => $has_more,
'next_cursor' => $has_more ?
['mode' => 'range', 'before' => $oldest] : null];
}
/**
* Returns one window of already-ordered rows in the date-oldest-
* first view, paged by position: the cursor names how many rows
* have already been shown and this returns the next run.
*
* @param array $rows the ordered, filtered rows (oldest first)
* @param int $window how many to return
* @param array|null $cursor the previous window's cursor, or null
* @param int $total the folder's full message count
* @param array $sort the sort in effect
* @return array the listing result
*/
protected function offsetWindow($rows, $window, $cursor, $total,
$sort)
{
$offset = $cursor ? (int) ($cursor['offset'] ?? 0) : 0;
$page = array_slice($rows, $offset, $window);
$new_offset = $offset + count($page);
$has_more = $new_offset < count($rows);
return ['messages' => $page, 'total' => $total,
'sort' => $sort, 'sort_supported' => false,
'sort_oversized' => false, 'sort_date_only' => true,
'mode' => 'sorted', 'has_more' => $has_more,
'next_cursor' => $has_more ?
['mode' => 'sorted', 'offset' => $new_offset] : null];
}
/**
* Builds one inbox-row associative array from a MailSite
* metadata record. Fetches just the header block, parses
* Subject / From / Date with MailHeaderParser, and falls
* back to a placeholder row when the storage returns no
* header bytes (corrupted entry, race with delivery).
*
* @param object $site the MailSite handle to read with
* @param string $folder containing folder
* @param array $record the metadata row from listMessages
* @return array|null the shaped row, or null when the UID
* is invalid (skip in the list)
*/
protected function shapeMessageRow($site, $folder, $record)
{
$uid = (int) ($record['uid'] ?? 0);
if ($uid < 1) {
return null;
}
$flags = $record['flags'] ?? [];
$row = [
'UID' => $uid,
'SUBJECT' => '',
'FROM' => '',
'FROM_NAME' => '',
'DATE' => '',
'DATE_TS' => (int) ($record['internal_date'] ?? 0),
'IS_FLAGGED' => in_array('\\Flagged', $flags, true),
'IS_UNREAD' => !in_array('\\Seen', $flags, true),
'IS_ANSWERED' => in_array('\\Answered', $flags, true),
];
$header_bytes = $site->messageHeaderBytes(
$this->username, $folder, $uid);
if ($header_bytes === false || $header_bytes === '') {
$row['IS_PLACEHOLDER'] = true;
return $row;
}
$headers = MailHeaderParser::parse($header_bytes);
$row['SUBJECT'] = MailHeaderParser::decodeMimeWord(
$headers['subject'] ?? '');
$row['DATE'] = $headers['date'] ?? '';
if ($row['DATE'] !== '' && $row['DATE_TS'] === 0) {
$parsed_ts = strtotime($row['DATE']);
if ($parsed_ts !== false) {
$row['DATE_TS'] = $parsed_ts;
}
}
$from_pairs = MailHeaderParser::splitAddresses(
$headers['from'] ?? '');
if (!empty($from_pairs)) {
list($from_name, $from_email) = $from_pairs[0];
$row['FROM_NAME'] = $from_name;
$row['FROM'] = $from_email;
}
return $row;
}
/**
* {@inheritdoc}
*/
public function fetchMessage($folder, $uid)
{
$site = $this->mailSite();
$bytes = $site->fetchMessage($this->username, $folder,
$uid);
if ($bytes === false) {
throw new MailBackendException(tl(
'mail_backend_message_not_found', $uid));
}
return $bytes;
}
/**
* {@inheritdoc}
*/
public function messageHeaderBytes($folder, $uid)
{
$site = $this->mailSite();
$bytes = $site->messageHeaderBytes($this->username,
$folder, $uid);
if ($bytes === false) {
throw new MailBackendException(tl(
'mail_backend_message_not_found', $uid));
}
return $bytes;
}
/**
* Returns the raw header bytes of every message in a folder,
* one entry per message. Used by the mail-clone job to index
* a destination folder's existing Message-IDs so it can skip
* re-importing a message the local mailbox already holds.
* Reads headers only (not bodies). A folder that does not
* exist or is empty yields an empty array. Messages whose
* header bytes cannot be read are omitted rather than
* throwing, so one unreadable local message does not abort
* the whole index.
*
* @param string $folder folder to enumerate
* @return array list of raw header byte strings
*/
public function listMessageHeaders($folder)
{
$site = $this->mailSite();
$records = $site->listMessages($this->username, $folder);
if (empty($records)) {
return [];
}
$headers = [];
foreach ($records as $record) {
$uid = (int) ($record['uid'] ?? 0);
if ($uid < 1) {
continue;
}
$header_bytes = $site->messageHeaderBytes(
$this->username, $folder, $uid);
if ($header_bytes === false || $header_bytes === '') {
continue;
}
$headers[] = $header_bytes;
}
return $headers;
}
/**
* {@inheritdoc}
*/
public function messageCount($folder, $unread_only = false)
{
$site = $this->mailSite();
if (!$unread_only) {
$total = $site->messageCount($this->username, $folder);
return $total === -1 ? 0 : (int) $total;
}
$records = $site->listMessages($this->username, $folder);
if (empty($records)) {
return 0;
}
$unread = 0;
foreach ($records as $rec) {
$flags = $rec['flags'] ?? [];
if (!in_array('\\Seen', $flags, true)) {
$unread++;
}
}
return $unread;
}
/**
* {@inheritdoc}
*/
public function setFlagBulk($folder, $uids, $flag, $value)
{
foreach ($uids as $uid) {
$this->setFlag($folder, (int) $uid, $flag, $value);
}
}
/**
* {@inheritdoc}
*/
public function moveMessagesBulk($folder, $uids, $destination)
{
foreach ($uids as $uid) {
$this->moveMessage($folder, (int) $uid, $destination);
}
}
/**
* {@inheritdoc}
*/
public function deleteMessagesBulk($folder, $uids)
{
if (strcasecmp($folder, 'Trash') === 0) {
$this->purgeMessagesBulk($folder, $uids);
return;
}
foreach ($uids as $uid) {
$this->deleteMessage($folder, (int) $uid);
}
}
/**
* {@inheritdoc}
*/
public function purgeMessagesBulk($folder, $uids)
{
$site = $this->mailSite();
foreach ($uids as $raw_uid) {
$uid = (int) $raw_uid;
$meta = $site->messageMeta($this->username,
$folder, $uid);
if ($meta === false) {
continue;
}
$flags = $meta['flags'] ?? [];
if (!in_array('\\Deleted', $flags, true)) {
$flags[] = '\\Deleted';
}
$site->setFlags($this->username, $folder,
$uid, $flags);
}
$site->expunge($this->username, $folder);
}
/**
* {@inheritdoc}
*/
public function setFlag($folder, $uid, $flag, $value)
{
$site = $this->mailSite();
$meta = $site->messageMeta($this->username, $folder, $uid);
if ($meta === false) {
throw new MailBackendException(tl(
'mail_backend_message_not_found', $uid));
}
$flags = $meta['flags'] ?? [];
$present = in_array($flag, $flags, true);
if ($value && !$present) {
$flags[] = $flag;
} else if (!$value && $present) {
$flags = array_values(array_filter($flags,
function ($candidate) use ($flag) {
return $candidate !== $flag;
}));
} else {
return;
}
$site->setFlags($this->username, $folder, $uid, $flags);
}
/**
* {@inheritdoc}
*/
public function appendMessage($folder, $bytes, $flags = [])
{
$site = $this->mailSite();
$uid = $site->appendMessage($this->username, $folder,
$bytes, $flags);
return ($uid === false || $uid <= 0) ? null : (int) $uid;
}
/**
* Appends a message preserving the source server's
* INTERNALDATE so the cloned copy carries the original
* delivery timestamp instead of the wall-clock at append
* time. Used by MailCloneJob when copying from an external
* IMAP server: matching INTERNALDATE keeps the local
* inbox sorted the same way the source server presents it.
*
* @param string $folder destination folder name
* @param string $bytes raw RFC 5322 message
* @param array $flags IMAP flag list (e.g. ['\\Seen'])
* @param int $internal_date source INTERNALDATE as unix
* timestamp; 0 means "use now" (MailStorage's default
* behavior)
* @return int|null new UID or null on failure
*/
public function appendMessageWithInternalDate($folder,
$bytes, $flags, $internal_date)
{
$site = $this->mailSite();
$uid = $site->appendMessage($this->username, $folder,
$bytes, $flags, (int) $internal_date);
return ($uid === false || $uid <= 0) ? null : (int) $uid;
}
/**
* {@inheritdoc}
*/
public function moveMessage($folder, $uid, $destination)
{
$site = $this->mailSite();
$ok = $site->moveMessage($this->username, $folder,
$destination, $uid);
if (!$ok) {
throw new MailBackendException(tl(
'mail_backend_move_failed', $uid));
}
}
/**
* {@inheritdoc}
*/
public function deleteMessage($folder, $uid)
{
if ($folder === 'Trash') {
$this->setFlag($folder, $uid, '\\Deleted', true);
$site = $this->mailSite();
$site->expunge($this->username, $folder);
return;
}
$this->moveMessage($folder, $uid, 'Trash');
}
/**
* {@inheritdoc}
*/
public function purgeFolder($folder)
{
$site = $this->mailSite();
$records = $site->listMessages($this->username, $folder);
foreach ($records as $rec) {
$uid = (int) ($rec['uid'] ?? 0);
if ($uid < 1) {
continue;
}
$flags = $rec['flags'] ?? [];
if (!in_array('\\Deleted', $flags, true)) {
$flags[] = '\\Deleted';
}
$site->setFlags($this->username, $folder, $uid, $flags);
}
$site->expunge($this->username, $folder);
}
/**
* {@inheritdoc}
*/
public function search($folder, $query)
{
$needle = strtolower(trim((string) $query));
if ($needle === '') {
return [];
}
$site = $this->mailSite();
return $site->searchMessages($this->username, $folder,
$needle);
}
/**
* {@inheritdoc}
*/
public function close()
{
/* MailSite is in-process; nothing to release. */
}
}