/ src / models / MailAliasModel.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\models;

use seekquarry\yioop\configs as C;

/**
 * Persists mail aliases: alternate addresses that deliver to a
 * Yioop user's local mailbox and that the user (or an admin) can
 * pick as a From address when composing. An alias is a local-part
 * paired with one configured mail domain, so an alias row for
 * "chris" at "example.com" owned by user "cpollett" makes
 * chris@example.com deliver to cpollett, while chris at another
 * domain is a separate alias that may be unclaimed or owned by
 * someone else.
 *
 * An alias shares the username namespace: its local-part must not
 * collide with an existing Yioop account name, and no two users
 * may claim the same alias address. The MAIL_ALIAS table's unique
 * index on (ALIAS, DOMAIN) enforces the address-uniqueness half at
 * the database level; aliasInUse adds the account-name half and is
 * the check call sites should make before adding.
 */
class MailAliasModel extends Model
{
    /**
     * Reports whether a proposed alias name is already claimed,
     * either by an existing Yioop account name or by an existing
     * alias (any user's). Comparison is case-insensitive, matching
     * how account names and inbound recipient local-parts are
     * compared elsewhere. The optional $ignore_user_id excludes a
     * user's own aliases from the alias half of the check so a
     * re-add of an alias the user already owns is not reported as
     * a conflict; account-name collisions are always reported.
     *
     * @param string $name proposed alias local-part
     * @param string $domain the mail domain the alias is for
     * @param int $ignore_user_id user whose own aliases to ignore,
     *      or 0 to consider every alias
     * @return bool true when the name is already in use
     */
    public function aliasInUse($name, $domain, $ignore_user_id = 0)
    {
        $db = $this->db;
        $name = trim((string) $name);
        $domain = strtolower(trim((string) $domain));
        if ($name === "" || $domain === "") {
            return true;
        }
        $sql = "SELECT USER_ID FROM USERS " .
            "WHERE LOWER(USER_NAME) = LOWER(?) " .
            $db->limitOffset(1);
        $result = $db->execute($sql, [$name]);
        if ($result && $db->fetchArray($result)) {
            return true;
        }
        $sql = "SELECT ID FROM MAIL_ALIAS " .
            "WHERE LOWER(ALIAS) = LOWER(?) AND LOWER(DOMAIN) = ?";
        $parameters = [$name, $domain];
        if ($ignore_user_id > 0) {
            $sql .= " AND USER_ID <> ?";
            $parameters[] = (int) $ignore_user_id;
        }
        $sql .= " " . $db->limitOffset(1);
        $result = $db->execute($sql, $parameters);
        if ($result && $db->fetchArray($result)) {
            return true;
        }
        return false;
    }

    /**
     * Adds an alias for a user after confirming the name is not
     * already claimed by an account or another user's alias. A
     * no-op returning false when the name is unavailable, so a
     * caller can branch on the result to surface a message.
     *
     * @param int $user_id owning Yioop user id
     * @param string $alias the alias local-part to add
     * @param string $domain the mail domain the alias is for
     * @return bool true when the alias was added
     */
    public function addAlias($user_id, $alias, $domain)
    {
        $db = $this->db;
        $alias = trim((string) $alias);
        $domain = strtolower(trim((string) $domain));
        if ($alias === "" || $domain === "" ||
            $this->aliasInUse($alias, $domain)) {
            return false;
        }
        $sql = "INSERT INTO MAIL_ALIAS " .
            "(USER_ID, ALIAS, DOMAIN, CREATED_AT) " .
            "VALUES (?, ?, ?, ?)";
        $db->execute($sql,
            [(int) $user_id, $alias, $domain, time()]);
        return true;
    }

    /**
     * Removes one alias owned by a user. Scoped to the owning user
     * so a request cannot delete another user's alias. Matching is
     * case-insensitive on the alias name.
     *
     * @param int $user_id owning Yioop user id
     * @param string $alias the alias local-part to remove
     * @param string $domain the mail domain of the alias to remove
     */
    public function removeAlias($user_id, $alias, $domain)
    {
        $db = $this->db;
        $sql = "DELETE FROM MAIL_ALIAS " .
            "WHERE USER_ID = ? AND LOWER(ALIAS) = LOWER(?) " .
            "AND LOWER(DOMAIN) = ?";
        $db->execute($sql, [(int) $user_id,
            trim((string) $alias),
            strtolower(trim((string) $domain))]);
    }

    /**
     * Returns the aliases a user owns as alias/domain pairs, sorted
     * for a stable display order. Each entry is an associative
     * array with 'ALIAS' and 'DOMAIN' keys, together forming the
     * full alias address.
     *
     * @param int $user_id owning Yioop user id
     * @return array list of ['ALIAS' => ..., 'DOMAIN' => ...]
     */
    public function aliasesForUser($user_id)
    {
        $db = $this->db;
        $sql = "SELECT ALIAS, DOMAIN FROM MAIL_ALIAS " .
            "WHERE USER_ID = ? ORDER BY ALIAS ASC, DOMAIN ASC";
        $result = $db->execute($sql, [(int) $user_id]);
        $aliases = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $aliases[] = ['ALIAS' => $row['ALIAS'],
                    'DOMAIN' => $row['DOMAIN']];
            }
        }
        return $aliases;
    }

    /**
     * Lists the addresses a user may send mail as: their primary
     * address followed by every alias they own (each written as
     * alias@domain). Used to populate the "From" choices on the
     * compose form.
     *
     * @param int $user_id the user whose identities are wanted
     * @param string $primary the user's primary sending address,
     *      placed first
     * @return array the primary address followed by the alias
     *      addresses
     */
    public function identitiesFor($user_id, $primary)
    {
        $identities = [$primary];
        foreach ($this->aliasesForUser($user_id) as $entry) {
            $identities[] = $entry["ALIAS"] . '@' . $entry["DOMAIN"];
        }
        return $identities;
    }

    /**
     * Returns the owning user id for an alias address, or 0 when no
     * such alias is registered. Used by inbound delivery to route a
     * recipient whose local-part is an alias to the right mailbox.
     * Both the local-part and the domain must match, so an alias
     * resolves only at the domain it was created for. Matching is
     * case-insensitive.
     *
     * @param string $alias the alias local-part to resolve
     * @param string $domain the recipient domain to resolve at
     * @return int owning user id, or 0 when not found
     */
    public function userIdForAlias($alias, $domain)
    {
        $db = $this->db;
        $sql = "SELECT USER_ID FROM MAIL_ALIAS " .
            "WHERE LOWER(ALIAS) = LOWER(?) AND LOWER(DOMAIN) = ? " .
            $db->limitOffset(1);
        $result = $db->execute($sql, [trim((string) $alias),
            strtolower(trim((string) $domain))]);
        if ($result) {
            $row = $db->fetchArray($result);
            if ($row && isset($row['USER_ID'])) {
                return (int) $row['USER_ID'];
            }
        }
        return 0;
    }
}
X