/ src / library / mail / MailBackend.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;
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();
}
X