<?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;
use seekquarry\yioop\controllers\components\SocialComponent;
/**
* Abstract backend interface decoupling the Yioop webmail UI from
* the storage technology behind any given account. Two concrete
* subclasses exist:
*
* - ImapMailBackend: speaks IMAP4rev1 over a TCP connection to a
* remote server. Used for accounts the user has added under
* Mail Settings -> External Mail Accounts.
*
* - MailSiteMailBackend: speaks directly to the in-process atto
* MailSite via the MailStorage interface. Used for the
* synthetic "this Yioop install runs its own mail server"
* account.
*
* The handlers in SocialComponent::userMail*() dispatch through
* MailBackend::forAccountId() and call the interface methods below,
* so each handler stays free of backend conditionals; the backend
* choice happens once, at the top, and the rest of the handler is
* backend-agnostic.
*
* Design conventions:
*
* - Identity is bound in the backend instance, not passed at
* every call. listMessages($folder, $window) NOT
* listMessages($account, $folder, $window).
*
* - Connections are lazy. The IMAP backend opens its TCP socket
* and runs LOGIN on the first method call that needs the
* server; MailSite has no connection step. Callers should
* invoke close() in a finally block to release the IMAP socket
* even if a method throws; close() on MailSite is a no-op.
*
* - Errors are surfaced via MailBackendException. The exception
* message is already localized via tl(); callers can put it
* into $data['MAIL_ERROR'] and the view renders it as-is.
*
* - Delete is a two-step model:
* deleteMessage($folder, $uid) moves the message to Trash
* purgeFolder('Trash') permanently removes everything
* This matches Yioop's UI -- the user clicks Delete to move
* to Trash, and a separate Empty Trash action permanently
* removes. The interface deliberately does not surface IMAP's
* STORE +FLAGS \\Deleted / EXPUNGE primitives directly; the
* IMAP backend translates the two-step model down to those
* commands internally.
*
* @author Chris Pollett
*/
abstract class MailBackend
{
/**
* Resolves an account_id to a concrete backend instance.
* MAILSITE_ACCOUNT_ID returns a MailSiteMailBackend; any other
* value loads the account row from MailAccountModel and returns
* an ImapMailBackend.
*
* @param int $account_id the account identifier; equals
* SocialComponent::MAILSITE_ACCOUNT_ID for the synthetic
* MailSite account
* @param array $data the userMailBaseData() result, providing
* USER_ID / USER_NAME / MAILSITE_ENABLED context
* @param array $account the owned account row, already loaded
* with its decrypted password by the calling component;
* ignored for the synthetic MailSite account and may be
* false there. Passed in rather than looked up here so this
* factory never reaches into a model.
* @param SocialComponent $component the calling component, handed
* to the IMAP backend so it can route logging through
* userMailLog
* @return MailBackend a ready-to-use backend (not yet connected
* for the IMAP case; first method call opens the socket)
* @throws MailBackendException when the account_id does not
* resolve to either a synthetic MailSite account or a
* real owned IMAP account row
*/
public static function forAccountId($account_id, $data, $account,
$component)
{
if (!empty($data["MAILSITE_ENABLED"]) && $account_id ===
SocialComponent::MAILSITE_ACCOUNT_ID) {
$username = $data["USER_NAME"] ?? '';
if ($username === '') {
throw new MailBackendException(tl(
'mail_backend_no_user_name'));
}
return new MailSiteMailBackend($username);
}
if (!$account) {
throw new MailBackendException(tl(
'social_component_invalid_account'));
}
return new ImapMailBackend($account, $component);
}
/**
* @return int the account identifier this backend is bound to;
* MAILSITE_ACCOUNT_ID for the synthetic account
*/
abstract public function accountId();
/**
* @return string user-visible display name for this account,
* shown in the sidebar and message headers
*/
abstract public function displayName();
/**
* @return string the user's own email address for this
* account, used by reply-quoting to filter the user
* out of reply_all's CC list and as the From: header
* when sending. For IMAP this resolves the configured
* sender address (via MailAccountModel::senderEmail);
* for MailSite this is the synthetic-account username
* which is itself an email address.
*/
abstract public function senderEmail();
/**
* @return bool true when this backend is the synthetic per-user
* MailSite account (so handlers know to skip account-
* ownership DB lookups)
*/
abstract public function isSyntheticAccount();
/**
* Returns the folder this account's inbox should open to when the
* user has not asked for a specific one. Always a usable folder
* name, falling back to the standard inbox.
*
* @return string the folder name to open by default
*/
abstract public function defaultFolder();
/**
* Lists folders on this account.
*
* @return array list of folder rows in the shape
* ImapFolderListParser::parse() returns: each row has
* NAME, DELIMITER, SELECTABLE, SPECIAL_USE, NO_INFERIORS
* @throws MailBackendException on transport failure
*/
abstract public function listFolders();
/**
* Annotates each selectable folder in $folders (and any
* nested CHILDREN folders) with an UNREAD integer count.
* IMAP runs STATUS UNSEEN per folder (with session caching).
* MailSite reads the per-folder unread count from its
* storage. Mutates $folders in place rather than returning
* a new list because folder rows are deeply nested
* (CHILDREN) and the caller already has the structure.
*
* Failures on individual folders are silent: the folder
* simply does not get an UNREAD key. The sidebar then
* renders that folder without a badge, matching the freshly-
* loaded pre-probe state.
*
* @param array &$folders folder list to annotate
*/
abstract public function annotateUnreadCounts(&$folders);
/**
* Creates a folder at the given hierarchical path.
*
* @param string $name the full path (with delimiter) for the
* new folder
* @throws MailBackendException on transport failure or refusal
* (folder already exists, illegal name, etc.)
*/
abstract public function createFolder($name);
/**
* Renames a folder. Both names are full paths.
*
* @param string $from existing folder path
* @param string $to desired new folder path
* @throws MailBackendException on transport failure or refusal
*/
abstract public function renameFolder($from, $to);
/**
* Deletes a folder. Whether the folder must be empty depends
* on the underlying backend (most IMAP servers reject DELETE
* on a non-empty mailbox); callers should empty the folder
* first if portability matters.
*
* @param string $name folder path to delete
* @throws MailBackendException on transport failure or refusal
*/
abstract public function deleteFolder($name);
/**
* Lists one window of a folder's messages for the inbox view,
* honoring the sort, the search filter, and the unread-only and
* flagged-only toggles in the request, and serving both the first
* page and later "load more" windows. Each backend implements
* this for its own storage (a remote IMAP server, or local
* MailSite storage) so the caller lists any account the same way.
*
* The request is an array with "window" (how many to return),
* "sort" (a "key"/"reverse" pair), "filter" (a free-text term, or
* empty), "unread_only" and "flagged_only" (the toggles),
* "unreadable_subject" (already-translated text for a message
* that cannot be fetched), and "cursor" (null for the first page,
* or the "next_cursor" from the previous window). A caller that
* already learned this server's sort support from an earlier page
* may also pass "sort_supported" so the backend need not ask the
* server again.
*
* The result is an array with "messages" (the rows, with raw
* subject and sender for the caller to clean), "total" (the count
* to show), "sort" (the sort actually applied), "sort_supported"
* (whether subject/sender sorting is available), "sort_oversized"
* (true when a requested sort was too large and the default view
* was shown instead), "mode" ("range" or "sorted"), "next_cursor"
* (pass back for the following window, or null), and "has_more".
*
* @param string $folder folder path to list
* @param array $request the listing request described above
* @return array the listing result described above
* @throws MailBackendException on transport failure
*/
abstract public function listMessages($folder, $request);
/**
* Returns the raw RFC 5322 bytes of one message.
*
* @param string $folder folder containing the message
* @param int $uid persistent IMAP UID of the message
* @return string raw message bytes (headers + body)
* @throws MailBackendException on transport failure or when
* the message does not exist
*/
abstract public function fetchMessage($folder, $uid);
/**
* Returns only the RFC 5322 header block of a message,
* stopping at the first blank line. Faster than fetchMessage
* when the caller needs only Subject / From / Date /
* Delivered-To.
*
* @param string $folder folder containing the message
* @param int $uid persistent IMAP UID of the message
* @return string raw header bytes (no trailing blank line)
* @throws MailBackendException on transport failure or missing
* message
*/
abstract public function messageHeaderBytes($folder, $uid);
/**
* Counts messages in a folder, optionally restricting to
* unread.
*
* @param string $folder folder to count
* @param bool $unread_only when true, count only \\Seen-less
* messages; when false, count every message in the
* folder
* @return int the count
* @throws MailBackendException on transport failure
*/
abstract public function messageCount($folder,
$unread_only = false);
/**
* Sets or clears one of the standard IMAP flags on a batch
* of messages. The bulk variant of setFlag: same semantics
* applied across the given UID list. IMAP impl emits a
* single UID STORE with the comma-joined list; MailSite
* iterates over the UIDs internally.
*
* @param string $folder folder containing the messages
* @param array $uids list of persistent IMAP UIDs
* @param string $flag one of '\\Seen', '\\Flagged',
* '\\Answered' (must include the leading backslash)
* @param bool $value true to set, false to clear
* @throws MailBackendException on transport failure
*/
abstract public function setFlagBulk($folder, $uids, $flag,
$value);
/**
* Moves a batch of messages from one folder to another. The
* bulk variant of moveMessage. Atomic from the caller's
* point of view: either every UID is moved or none is. IMAP
* impl uses UID COPY + UID STORE + UID EXPUNGE for the
* batch; MailSite iterates.
*
* @param string $folder source folder
* @param array $uids list of persistent IMAP UIDs
* @param string $destination target folder path
* @throws MailBackendException on transport failure
*/
abstract public function moveMessagesBulk($folder, $uids,
$destination);
/**
* Soft-deletes a batch of messages: moves them to Trash, or
* permanently removes when $folder is already Trash (the
* "empty trash" semantic for a UID list). Bulk variant of
* deleteMessage.
*
* @param string $folder folder containing the messages
* @param array $uids list of persistent IMAP UIDs
* @throws MailBackendException on transport failure or when
* the Trash folder cannot be located
*/
abstract public function deleteMessagesBulk($folder, $uids);
/**
* Permanently removes a batch of messages from a folder,
* skipping the Trash step. Unlike deleteMessagesBulk this does
* not relocate the messages to Trash first; the UIDs are
* marked deleted and expunged in place. Used by a cross-account
* move, where the messages already live in the destination
* account and leaving a Trash copy on the source would defeat
* the point of a move.
*
* @param string $folder folder containing the messages
* @param array $uids list of persistent IMAP UIDs
* @throws MailBackendException on transport failure
*/
abstract public function purgeMessagesBulk($folder, $uids);
/**
* Sets or clears one of the standard IMAP flags on a message.
*
* @param string $folder folder containing the message
* @param int $uid persistent IMAP UID of the message
* @param string $flag one of '\\Seen', '\\Flagged', '\\Answered'
* (must include the leading backslash)
* @param bool $value true to set, false to clear
* @throws MailBackendException on transport failure
*/
abstract public function setFlag($folder, $uid, $flag, $value);
/**
* Appends a freshly-built message (raw RFC 5322 bytes) to a
* folder. Used for saving drafts and archiving outgoing
* messages to Sent.
*
* @param string $folder folder to append into
* @param string $bytes raw RFC 5322 message bytes
* @param array $flags optional initial flags (e.g. ['\\Seen'])
* @return int|null the new UID assigned by the server, or null
* when the backend cannot report it (some IMAP servers
* omit the UIDPLUS APPENDUID response)
* @throws MailBackendException on transport failure
*/
abstract public function appendMessage($folder, $bytes,
$flags = []);
/**
* Moves a message from one folder to another. Atomic from the
* caller's point of view (either both the copy and the
* source-delete succeed, or neither).
*
* @param string $folder source folder
* @param int $uid persistent IMAP UID of the message
* @param string $destination target folder path
* @throws MailBackendException on transport failure or when
* the destination does not exist
*/
abstract public function moveMessage($folder, $uid,
$destination);
/**
* Soft-deletes a message by moving it to Trash. To bypass
* Trash and permanently remove, use purgeFolder('Trash')
* after moving things in, or call moveMessage to a
* deliberately-different folder.
*
* @param string $folder folder containing the message
* @param int $uid persistent IMAP UID of the message
* @throws MailBackendException on transport failure or when
* the Trash folder cannot be located
*/
abstract public function deleteMessage($folder, $uid);
/**
* Permanently removes every message from a folder. Used to
* implement Empty Trash and similar bulk-wipe operations.
*
* @param string $folder folder to clear
* @throws MailBackendException on transport failure
*/
abstract public function purgeFolder($folder);
/**
* Searches a folder for messages matching a query expression.
* The exact query syntax is backend-defined: IMAP supports
* full SEARCH grammar (SUBJECT, FROM, TO, BODY, etc.);
* MailSite supports a simpler substring-match implementation.
* Callers should treat $query as opaque user-typed text and
* let the backend interpret.
*
* @param string $folder folder to search
* @param string $query the search expression
* @return array list of UIDs (ints) matching the query
* @throws MailBackendException on transport failure
*/
abstract public function search($folder, $query);
/**
* Releases any underlying connection or resources. Safe to
* call multiple times. Callers should put this in a finally
* block so a method that throws midway still leaves the
* backend cleanly shut down.
*/
abstract public function close();
}