/ src / library / mail / UnsubscribeToken.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\library as L;

/**
 * Builds and checks the signed tokens used in one-click mail
 * unsubscribe links. A token names the member and the group their
 * unsubscribe applies to, plus a signature so the link cannot be
 * altered to unsubscribe a different person or group. The signature is
 * an HMAC keyed by this site's secret AUTH_KEY, so a token only
 * verifies on the site that issued it and cannot be forged without
 * that key.
 */
class UnsubscribeToken
{
    /**
     * Mixed into the signed text so an unsubscribe signature can never
     * be mistaken for, or reused as, some other value the site signs
     * with the same key.
     */
    const SIGN_PREFIX = "mail-unsubscribe:";
    /**
     * Mixed into the signed text of a whole-site unsubscribe token. It
     * differs from SIGN_PREFIX so a token that turns off one group can
     * never be read as one that turns off all mail, and the reverse.
     */
    const DOMAIN_SIGN_PREFIX = "mail-unsubscribe-all:";
    /**
     * Builds the signed token that names one member and one group for
     * an unsubscribe link.
     *
     * @param int $user_id the member the link unsubscribes
     * @param int $group_id the group being unsubscribed from
     * @return string the two ids and their signature joined by dots,
     *      safe to drop straight into a URL
     */
    public static function make($user_id, $group_id)
    {
        $payload = intval($user_id) . "." . intval($group_id);
        return $payload . "." . self::signPayload(self::SIGN_PREFIX, $payload);
    }
    /**
     * Reads a token from an unsubscribe link and, when its signature
     * checks out, returns who and which group it names. Returns false
     * for anything that does not parse or whose signature does not
     * match, so a tampered or made-up link is simply rejected.
     *
     * @param string $token the token taken from the link
     * @return array|bool ['user_id' => int, 'group_id' => int] when the
     *      token is valid, or false when it is not
     */
    public static function parse($token)
    {
        $parts = explode(".", (string) $token);
        if (count($parts) !== 3) {
            return false;
        }
        list($user_id, $group_id, $signature) = $parts;
        if (!ctype_digit($user_id) || !ctype_digit($group_id)) {
            return false;
        }
        $payload = $user_id . "." . $group_id;
        if (!hash_equals(
            self::signPayload(self::SIGN_PREFIX, $payload),
            $signature)) {
            return false;
        }
        return ["user_id" => intval($user_id),
            "group_id" => intval($group_id)];
    }
    /**
     * Builds the signed token that names one email address for a
     * whole-site unsubscribe. The bot mailbox checks this signature
     * before it will turn off all of a site's mail to an address, so a
     * forged request naming someone else's address is rejected: only
     * this site, holding the secret key, can produce a token that
     * verifies.
     *
     * @param string $email the address the token unsubscribes
     * @return string the encoded address and its signature joined by a
     *      dot, safe to carry in a mail subject or address
     */
    public static function makeEmail($email)
    {
        $payload = rtrim(strtr(
            base64_encode(trim((string) $email)), "+/", "-_"), "=");
        return $payload . "." .
            self::signPayload(self::DOMAIN_SIGN_PREFIX, $payload);
    }
    /**
     * Reads a whole-site unsubscribe token and, when its signature
     * checks out, returns the email address it names. Returns false for
     * anything that does not parse or whose signature does not match, so
     * a forged or tampered request is simply ignored.
     *
     * @param string $token the token taken from the unsubscribe mail
     * @return string|bool the email address when the token is valid, or
     *      false when it is not
     */
    public static function parseEmail($token)
    {
        $parts = explode(".", (string) $token);
        if (count($parts) !== 2) {
            return false;
        }
        list($payload, $signature) = $parts;
        if (!hash_equals(
            self::signPayload(self::DOMAIN_SIGN_PREFIX, $payload),
            $signature)) {
            return false;
        }
        $email = (string) base64_decode(
            strtr($payload, "-_", "+/"));
        if ($email === "") {
            return false;
        }
        return $email;
    }
    /**
     * Builds the List-Unsubscribe mail headers for one recipient of a
     * group's mail. The recipient's mail program is handed two ways to
     * unsubscribe, each carrying a signed token naming this member and
     * group: a one-click web address and a mailto address. The second
     * header is the one-click marker (RFC 8058) that lets a single click
     * in the mail program turn the group's mail off without the member
     * having to open a page.
     *
     * @param int $user_id the member being mailed
     * @param int $group_id the group whose mail they may turn off
     * @param string $base_url site address the unsubscribe page lives on
     *      (the site the mail went out from); the web link is this plus
     *      the api unsubscribe query
     * @param string $mailto_address address that accepts unsubscribe mail
     *      (the bot mailbox when this site runs its own mail, otherwise
     *      the site's own from address)
     * @return array two headers as name => value: List-Unsubscribe (the
     *      mailto and web links) and List-Unsubscribe-Post (the one-click
     *      marker)
     */
    public static function listUnsubscribeHeaders($user_id, $group_id,
        $base_url, $mailto_address)
    {
        return self::headersForToken(self::make($user_id, $group_id),
            $base_url, $mailto_address);
    }
    /**
     * Builds the List-Unsubscribe mail headers for a recipient whose
     * link turns off ALL of this site's mail to their address. Used on
     * mail (such as a registration confirmation) that is not tied to any
     * one group. The recipient's mail program is handed the same two
     * ways to unsubscribe as the group headers, but the token names an
     * email address rather than a member and group.
     *
     * @param string $email the address being mailed
     * @param string $base_url site address the unsubscribe page lives on
     *      (the site the mail went out from)
     * @param string $mailto_address address that accepts unsubscribe mail
     * @return array two headers as name => value: List-Unsubscribe (the
     *      mailto and web links) and List-Unsubscribe-Post (the one-click
     *      marker)
     */
    public static function listUnsubscribeHeadersForEmail($email,
        $base_url, $mailto_address)
    {
        return self::headersForToken(self::makeEmail($email),
            $base_url, $mailto_address);
    }
    /**
     * Builds the web address of the unsubscribe page for an
     * already-made token, the link a recipient clicks (or that a mail
     * client follows) to reach the confirm / one-click page.
     *
     * @param string $token the signed unsubscribe token to carry
     * @param string $base_url site address the unsubscribe page lives on
     * @return string the full unsubscribe web address
     */
    public static function unsubscribeUrl($token, $base_url)
    {
        return $base_url . "?c=api&a=unsubscribe&token=" .
            urlencode($token);
    }
    /**
     * Builds the pair of List-Unsubscribe headers around an already-made
     * token, shared by the group and whole-site header builders. The
     * recipient's mail program is handed two ways to unsubscribe, each
     * carrying the token: a one-click web address and a mailto address.
     * The second header is the one-click marker (RFC 8058) that lets a
     * single click in the mail program unsubscribe without opening a
     * page.
     *
     * @param string $token the signed unsubscribe token to carry
     * @param string $base_url site address the unsubscribe page lives on
     * @param string $mailto_address address that accepts unsubscribe mail
     * @return array two headers as name => value: List-Unsubscribe (the
     *      mailto and web links) and List-Unsubscribe-Post (the one-click
     *      marker)
     */
    private static function headersForToken($token, $base_url,
        $mailto_address)
    {
        $web = self::unsubscribeUrl($token, $base_url);
        $mailto = "mailto:" . $mailto_address . "?subject=" .
            urlencode("unsubscribe " . $token);
        return [
            "List-Unsubscribe" => "<" . $mailto . ">, <" . $web . ">",
            "List-Unsubscribe-Post" => "List-Unsubscribe=One-Click",
        ];
    }
    /**
     * Signs a token's payload with this site's secret key. The prefix
     * keeps the two kinds of token apart: a one-group signature and a
     * whole-site signature over the same payload come out different, so
     * neither can be read as the other.
     *
     * @param string $prefix the kind-of-token prefix mixed into the
     *      signed text (SIGN_PREFIX or DOMAIN_SIGN_PREFIX)
     * @param string $payload the token body being signed
     * @return string the URL-safe signature for that payload
     */
    private static function signPayload($prefix, $payload)
    {
        return L\crawlAuthHash($prefix . $payload);
    }
}
X