<?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);
}
}