<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Email List Subscribe / Unsubscribe &mdash; Arc Plan</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
    margin: 1.5em auto; padding: 0 1em; line-height: 1.5;
    color: #1a1a1a; }
h1 { font-size: 1.4em; }
h2 { font-size: 1.1em; margin-top: 1.4em; }
ol { margin: 0.3em 0; }
li { margin: 0.25em 0; }
.check { color: #1a7f37; font-weight: bold; }
.next { color: #b35900; font-weight: bold; }
.q { color: #8250df; }
code { background: #f2f2f2; padding: 0 0.25em; border-radius: 3px; }
em { color: #555; }
</style>
</head>
<body>
<h1>Email List Subscribe / Unsubscribe</h1>
<p style="margin:.8rem 2rem;padding:.7rem 1rem;background:#eef2f7">
<strong>Arc Post-Mortem Summary.</strong>
Built the email-list subscribe and
unsubscribe flow. It added a per-group mail subscription with its checkbox
(Phase A), List-Unsubscribe headers on group and bulk mail plus a web
unsubscribe path (Phase B), registration and whole-domain unsubscribe (Phase
C), and the bot mailbox with MailSite configured (Phase D). Existing behavior
was verified first and the design decisions were resolved with Chris.</p>
<p><em>Arc started 2026-06-17. This is the List-Unsubscribe piece of
v10 ToDo item 12 (Message-ID / MIME-Version / Content-Type /
Content-Transfer-Encoding and DKIM signing already landed; the
List-Unsubscribe headers plus the web + mailto unsubscribe machinery
are what remain). Goal: let a
user turn off bulk mail from a group, keep unsubscribed users out of
the recipient lists, and attach RFC&nbsp;8058 List-Unsubscribe headers
(a one-click URL and a mailto address) to bulk and registration mail,
backed by a web endpoint and &mdash; when MailSite is configured
&mdash; a <code>bot@&lt;domain&gt;</code> mailbox that processes mailto
unsubscribes.</em></p>

<h2>What exists today (verified)</h2>
<ol>
  <li>Membership lives in <code>USER_GROUP (USER_ID, GROUP_ID, STATUS,
    JOIN_DATE)</code>, primary key <code>(GROUP_ID, USER_ID)</code>
    (<code>ProfileModel</code> fresh schema). No mail-subscription
    column yet.</li>
  <li><code>GroupModel::getThreadFollowers($thread_id, $owner_id,
    $exclude_id)</code> returns USER_ID / USER_NAME / EMAIL for everyone
    who posted in a thread plus the owner; it does not currently know
    the thread's group. <code>GroupModel::getGroupUsers($group_id,
    $filter, $sorts, ...)</code> returns every member.</li>
  <li>Bulk mail is queued as serialized four-element tuples
    <code>[subject, body, to, from]</code> in per-batch
    <code>.txt</code> files; <code>BulkEmailJob</code> reads them and
    calls <code>$mail_server->sendImmediate(...)</code>. The enqueue
    points with group context are in <code>SocialComponent</code> near
    line&nbsp;1516 (thread followers) and line&nbsp;1911 (group users).</li>
  <li><code>MimeMessage::build($from, $to, $subject, $body,
    $attachments, $extra_headers)</code> already accepts an
    <code>$extra_headers</code> array &mdash; the natural seam for the
    List-Unsubscribe headers.</li>
  <li><span class="check">&#10003;</span> DKIM signing is already wired:
    <code>SmtpClient::sendImmediate</code> wraps the built message in
    <code>self::dkimSign(...)</code> (which calls
    <code>DkimKey::sign</code> when a selector is configured, pass-through
    otherwise), so the List-Unsubscribe headers added via
    <code>$extra_headers</code> end up inside the signed message.</li>
</ol>

<h2>Phase A &mdash; per-group mail subscription + the checkbox</h2>
<ol>
  <li><span class="check">&#10003;</span> Added a <code>RECEIVE_MAIL</code>
    column to <code>USER_GROUP</code> (default 1 = subscribed) in the
    fresh schema, plus the portable v108 <code>ADD ... DEFAULT 1</code>
    migration that switches it on for every existing membership.</li>
  <li><span class="check">&#10003;</span> <code>GroupModel</code>:
    <code>getGroupUsers</code> gained a <code>$subscribed_only</code> flag
    (filters <code>RECEIVE_MAIL &lt;&gt; 0</code>);
    <code>getThreadFollowers</code> gained a <code>$mail_only</code> flag
    that, via a correlated <code>NOT EXISTS</code> on
    thread&rarr;group&rarr;<code>USER_GROUP</code>, drops members with
    <code>RECEIVE_MAIL = 0</code>. The two mail callers in
    <code>SocialComponent</code> (post&rarr;thread-followers,
    owner-post&rarr;group-users) now pass the flag; the contact-requests
    caller of <code>getThreadFollowers</code> stays unfiltered.</li>
  <li><span class="check">&#10003;</span> A "Receive Group Mail"
    checkbox on the group settings page, shown in <em>both</em> the
    editable (<code>editgroup</code>) and the read-only
    (<code>infogroup</code>) views as a row in the settings table (tied
    by its HTML <code>form</code> attribute to a separate hidden form, so
    ticking it saves only this choice and the main Save leaves it alone;
    a new <code>changemailpref</code> case checks the
    viewer is a member, via <code>checkUserGroup</code>, then writes
    their <code>RECEIVE_MAIL</code> and returns to the page it came
    from). Driven by new <code>GroupModel</code>
    <code>getMailSubscription</code> / <code>setMailSubscription</code>.
    So a read-only viewer can still unsubscribe themselves.</li>
  <li><span class="check">&#10003;</span> Thread level: a
    <code>THREAD_UNSUBSCRIBE</code> table (v109) recording per-user
    opt-outs; <code>GroupModel</code> <code>isThreadUnsubscribed</code> /
    <code>setThreadUnsubscribe</code>; the second arm of the
    <code>getThreadFollowers</code> exclusion (a <code>NOT EXISTS</code>
    on that table); and a Follow / Unfollow button on the thread view,
    wired to a <code>togglethreadmail</code> feed action that sets the
    opt-out (idempotent, so a refresh does not re-toggle) and returns to
    the thread.</li>
  <li><span class="check">&#10003;</span> Regression fix (DB-error
    sweep across recent arcs): positional <code>INSERT</code>s broke
    when a column was added to a table they target. (1)
    <code>GroupModel::addUserGroup</code>'s
    <code>INSERT INTO USER_GROUP VALUES (?,?,?,?)</code> now names its
    columns so <code>RECEIVE_MAIL</code> defaults &mdash; this is what
    made joining / creating a group fail. (2) Createdb.php fresh-install
    seeds: the eight USER_GROUP seeds now supply <code>RECEIVE_MAIL</code>
    (= 1) and the four SOCIAL_GROUPS seeds now supply
    <code>RENDER_ENGINE</code> (the OPTIONS arc added it to the schema at
    v106 but never to Createdb.php). (3) Swept every table our v100+
    migrations added a column to: MAIL_ALIAS (v102, DOMAIN),
    SOCIAL_GROUPS (v106), USER_GROUP (v108) &mdash; MAIL_ALIAS has no
    positional inserts, so the other two were the only casualties.
    Verified the fixed seeds insert cleanly at the current schema and the
    old value-counts still fail. Going-forward rule: when a table is
    altered, scan every insert into it.</li>
  <li><span class="check">&#10003;</span> Reworked thread level from
    opt-out to opt-in (Chris's call): by default you do <em>not</em>
    follow a thread; posting to or starting a thread follows it, and the
    state icon toggles it. The <code>THREAD_UNSUBSCRIBE</code> table (which
    recorded "stop mailing me") is replaced by <code>THREAD_FOLLOW</code>
    (which records "mail me about this thread"), via a v110 migration that
    drops the old table and creates the new one. <code>GroupModel</code>
    gains <code>isThreadSubscribed</code> /
    <code>setThreadSubscribe</code> and a <code>getThreadSubscribers</code>
    that lists a thread's followers with their addresses; the post-notify
    path mails that list, and <code>getThreadFollowers</code> reverts to
    its plain "thread participants" form for the contact-requests caller.
    The toggle action and the (temporary) Follow / Unfollow button now use
    a <code>follow</code> flag. <em class="q">Open choices: (a) icon will
    be a bell (following) / bell-with-slash (not following) unless Chris
    prefers another; (b) thread-follow currently mails replies independent
    of the group's RECEIVE_MAIL switch &mdash; following a thread mails you
    even with group mail off (alternative would be to treat RECEIVE_MAIL as
    a master mute).</em></li>
  <li><span class="check">&#10003;</span> UI: replaced the big button
    with a small toggle icon &mdash; a small bell (following) or
    bell-with-slash (not following) that shows the current state and
    toggles on click. The two bell icons are added to the standard
    <code>iconlink</code> helper. The breadcrumb bell uses
    <code>renderButton</code> (a boxed icon link); the feed-row bell uses
    <code>renderFormButton</code> with an onclick, exactly like the
    AI-summary button beside it, so they match in size and styling. Also
    tidied that helper method: fixed the <code>additional_attibutes</code>
    parameter typo (now <code>additional_attributes</code>) and switched
    the feed's AI-summary <code>echo</code>s to <code>e()</code>. The bell
    sits in the breadcrumb menu bar right after the thread name on the
    thread view, and on each thread row in
    the feed just after the AI-summary control. The feed needs to know the
    follow state of every thread it lists, so the controller looks them up
    in one query (<code>getFollowedThreads</code>) and the toggle action
    now takes a <code>follow_thread</code> id so a feed row can follow the
    thread it names and return to the feed.</li>
</ol>

<h2>Phase B &mdash; List-Unsubscribe on group / bulk mail + the web
endpoint</h2>
<p><em>Foundation (done): the make / parse primitive that items 3 and 4
both share &mdash; <code>UnsubscribeToken::make($user_id, $group_id)</code>
/ <code>parse($token)</code> in
<code>src/library/mail/UnsubscribeToken.php</code>: the two ids plus an
<code>AUTH_KEY</code>-keyed HMAC, joined by dots, URL safe. Item 3 builds
the link with <code>make</code>; item 4 validates it with
<code>parse</code>. Covered by <code>UnsubscribeTokenTest</code>
(round-trip, tampered ids, forged signature, malformed input). This is
preparation, not a numbered deliverable.</em></p>
<ol>
  <li><span class="check">&#10003;</span> Decided the unsubscribe mailto
    address:
    <code>MailSiteFactory::unsubscribeMailtoAddress($root_email)</code>
    returns <code>bot@&lt;first MAIL_DOMAIN&gt;</code> when this Yioop runs
    its own mail, otherwise the root account address passed in. Kept it
    pure (reads config, takes the root e-mail as a parameter, touches no
    model) so as not to widen the factory's existing model-use exception.
    (Also fixed two pre-existing &gt;80 lines in GroupfeedElement noticed
    while here.)</li>
  <li><span class="check">&#10003;</span> Carried the unsubscribe header
    through to the send path. Decided to precompute the List-Unsubscribe
    header at enqueue and carry it (rather than stuff raw group +
    recipient into the mail tuple): added an <code>$extra_headers</code>
    argument to <code>SmtpClient::send()</code> and <code>sendQueue()</code>
    (forwarded to <code>sendImmediate</code>, which already supported it),
    queued records now serialize a five-element tuple, and
    <code>BulkEmailJob</code>'s two readers accept four- or five-element
    tuples so queued files written before the upgrade still send (with no
    extra headers). Also added the missing <code>is_array</code> guard in
    <code>doTasks</code> so a corrupt queued record no longer trips
    <code>count(false)</code>.</li>
  <li><span class="check">&#10003;</span> Attach, via
    <code>MimeMessage::build</code>'s <code>$extra_headers</code>:
    <code>List-Unsubscribe: &lt;mailto:...&gt;, &lt;https://...&gt;</code>
    and <code>List-Unsubscribe-Post: List-Unsubscribe=One-Click</code>
    (RFC&nbsp;8058). Built by a pure
    <code>UnsubscribeToken::listUnsubscribeHeaders($user_id, $group_id,
    $base_url, $mailto_address)</code> &mdash; the web link is the item&nbsp;4
    endpoint with a <code>make</code> token, the mailto is item&nbsp;1's
    <code>unsubscribeMailtoAddress</code> carrying the same token in its
    subject, plus the one-click marker. Wired into the group-users bulk
    path in <code>SocialComponent</code> (the new-thread-by-owner
    notification): each recipient's message now carries headers that turn
    off that user + group's mail on the site it went out from. Covered by
    a new <code>listUnsubscribeHeadersTestCase</code>; full suite green.
    <span class="q">Open (Chris to decide):</span> the other group mail
    path, thread-follow notifications, is deliberately left untouched
    &mdash; a <em>group</em> unsubscribe there would be wrong (it would
    turn group mail off while the follow mail, which is independent of
    <code>RECEIVE_MAIL</code>, kept arriving). That path wants its own
    thread-level unsubscribe (drop <code>THREAD_FOLLOW</code> for that
    thread), which is a separate token + endpoint action. The owner
    join-request notice (a single-recipient operational mail) is left as
    plain mail.</li>
  <li><span class="check">&#10003;</span> A web unsubscribe endpoint (a route, like the robots / mta-sts
    routes): the signed token identifies user + group; a one-click POST
    performs the unsubscribe (a plain GET shows a small confirm page so
    a link preview fetch does not silently unsubscribe).
    <span class="check">&#10003;</span> Decided (Chris): the endpoint
    lives in <code>ApiController</code>, whose role is being widened from
    the LLM activities (summarize / transcribe / translate) to general
    endpoints. As prep, <code>ApiController</code> was brought to style
    conformance this patch (wrapped four long lines, the one
    <code>//</code> comment turned into <code>/* */</code>,
    <code>$result_obj</code> renamed off the banned <code>_obj</code>
    suffix to <code>$decoded</code>, generic <code>$model</code> renamed
    to <code>$llm_model</code>).
    <span class="check">&#10003;</span> Done (this session): added the
    <code>unsubscribe</code> activity to <code>ApiController</code>. It
    cleans the <code>token</code>, runs it through
    <code>UnsubscribeToken::parse</code>, and on a bad token shows a short
    "invalid or expired" page. For a good token it looks up the group
    name; a GET shows a confirmation page whose button POSTs the token
    back (so link scanners following the GET cannot unsubscribe anyone),
    and a POST calls <code>GroupModel::setMailSubscription(..., 0)</code>
    to turn that group's mail off for that user. The page is a small
    self-contained localized HTML document written straight to the
    response and ended with <code>webExit()</code>, the same emit-and-stop
    idiom the API JSON path uses, so it renders identically on the atto
    server and Apache. Five <code>api_unsubscribe_*</code> locale strings
    added. Covered by a new <code>ApiUnsubscribeTest</code> (the repo's
    first controller test): it builds a throwaway membership row in an
    isolated SQLite database, injects it into the controller, and checks
    that an invalid link shows no form and changes nothing, a GET shows
    the confirm form carrying the token without unsubscribing, and a POST
    actually flips <code>RECEIVE_MAIL</code> to off. Full unit suite
    40907/40907 green.
    <span class="check">&#10003;</span> Refactored (Chris's review): the
    activity no longer prints HTML or branch on
    <code>$_SERVER['REQUEST_METHOD']</code>. It now returns view data and
    lets a new <code>UnsubscribeView</code> (web layout) draw the page
    directly &mdash; no separate element, since nothing here is swapped
    out &mdash; and it decides
    confirm-vs-unsubscribe from a request parameter
    (<code>List-Unsubscribe=One-Click</code>, the RFC&nbsp;8058 marker the
    mail client or the confirm button sends) read through
    <code>$_REQUEST</code>, keeping the endpoint agnostic to GET vs POST.
    Test reworked to assert the returned state and the flipped
    subscription; full suite 40913/40913 green.
    <span class="check">&#10003;</span> Extended (this patch) for
    whole-site unsubscribe: the same endpoint now also accepts the
    whole-site (email-address) token. After the member-and-group parse
    fails it tries <code>UnsubscribeToken::parseEmail</code>; a valid
    one-click then calls <code>MailSuppressionModel::suppress</code> to
    turn off all of this site's mail to that address, and
    <code>UnsubscribeView</code> gained an all-mail confirm / done
    message (two new <code>api_unsubscribe_all_*</code> locale strings).
    Routing is by token shape, so the group path is untouched.
    <code>ApiUnsubscribeTest</code> gains whole-site confirm and
    one-click-suppress cases.</li>
</ol>

<h2>Phase C &mdash; registration / whole-domain unsubscribe</h2>
<ol>
  <li><span class="check">&#10003;</span> Done (this patch): a suppressed
    email address is burned &mdash; it can no longer be used to register.
    At registration <code>dataIntegrityCheck</code> consults the
    suppression list and, when the address is on it, refuses the account
    and shows the user a message that the address can no longer be used;
    no account is created and no activation mail is sent. The check is
    gated to registration only (password recovery stays transactional, so
    an existing user can still recover). Covered by
    <code>RegisterSuppressionTest</code>.</li>
  <li><span class="check">&#10003;</span> Done: a suppression store keyed
    by email address, and the send path skips any address on it.
    Store &mdash; a <code>MAIL_SUPPRESSION</code> table (one row per
    address, unique index on EMAIL) added to the fresh schema and via
    <code>upgradeDatabaseVersion111</code> (DATABASE_VERSION 110 &rarr;
    111), plus a <code>MailSuppressionModel</code> with
    <code>isSuppressed</code> / <code>suppress</code> /
    <code>unsuppress</code> (case-insensitive, mirrors
    <code>MailSenderAllowModel</code>), covered by
    <code>MailSuppressionModelTest</code>.
    Skip &mdash; the check lives in <code>BulkEmailJob</code> (the queue
    consumer). Before sending each queued message it holds the recipient
    back only when the message is list mail &mdash; it carries a
    <code>List-Unsubscribe</code> header &mdash; and the address is on the
    suppression list. Transactional mail such as a password reset carries
    no <code>List-Unsubscribe</code> and is always sent, so a suppressed
    address can still recover their account. Covered by
    <code>BulkEmailJobTest</code>.</li>
  <li><span class="check">&#10003;</span> Done (this patch): the
    activation email offers the whole-site unsubscribe.
    <code>RegisterController::sendActivationMail</code> now adds the
    whole-site List-Unsubscribe headers (one-click via the bot mailbox
    and web links, from <code>listUnsubscribeHeadersForEmail</code>) and
    a plain visible "stop all mail from this site" line at the foot of
    the message pointing at the web endpoint
    (<code>UnsubscribeToken::unsubscribeUrl</code>, shared with the
    header builder so the two links never drift). Only the activation
    mail carries it &mdash; password recovery stays plain. Safe because
    the registration gate already refuses a suppressed address, so this
    mail never goes to a suppressed recipient. The group and whole-site
    header builders now share one private helper; covered by
    <code>UnsubscribeTokenTest</code>.</li>
</ol>

<h2>Phase D &mdash; the <code>bot</code> mailbox (MailSite configured)</h2>
<ol>
  <li><span class="check">&#10003;</span> Done (this patch): the username
    <code>bot</code> is reserved &mdash; it is added to the registration
    forbidden-name list, so no account may be created as <code>bot</code>
    and the <code>bot@&lt;domain&gt;</code> mailbox identity stays owned by
    the system. Covered by <code>RegisterSuppressionTest</code>.</li>
  <li><span class="check">&#10003;</span> Done: <code>BulkEmailJob</code>
    reads incoming <code>bot@&lt;domain&gt;</code> mail and applies the
    same unsubscribe a click would. The forgery guard
    (<code>UnsubscribeToken::makeEmail</code> / <code>parseEmail</code>,
    earlier patch) signs the email address itself with the site secret
    under a distinct all-mail prefix; the bot trusts only this
    signature, never the spoofable <code>From</code>, so an altered
    signature, a signature reused under a different address, or a plain
    unsigned address are all rejected (covered by
    <code>UnsubscribeTokenTest</code>). The reader
    (<code>processBotMailbox</code>, this patch) runs at the top of the
    name-server mail tick: when a local store has a <code>bot</code>
    INBOX it walks each message, unfolds the Subject (the mailto links
    produce "unsubscribe &lt;token&gt;"), and on a verified token
    suppresses the address (whole-site token) or turns off the group
    (member-and-group token) through <code>applyUnsubscribeToken</code>,
    then flags and expunges every examined message so stray mail to the
    bot address is cleared too. The store is reached through a new
    shared <code>MailSiteFactory::storage()</code> and the mailbox name
    lives in one <code>UNSUBSCRIBE_MAILBOX</code> constant. Covered by
    <code>BulkEmailJobTest</code> (group token, whole-site token, forged
    token ignored, mailbox process-and-clear, stray-mail clear).</li>
</ol>

<h2>Other work this phase</h2>
<ol>
  <li class="q">Found-bug fix folded in this patch:
    <code>MailRecordCache</code> did <code>new LRUCache(...)</code>
    without importing it, so it resolved to a non-existent
    <code>...\library\mail\LRUCache</code> and threw "class not
    found" the moment <code>codetool unit</code> reached
    <code>DmarcCheckTest</code> (which warms the cache). Added the
    missing <code>use</code>; the full unit suite now runs to
    completion. (The two Index test failures that then surfaced are
    sandbox-only &mdash; PHP 8.3 here vs 8.5 on the dev box &mdash; and
    pass on Chris's box, so left alone.)</li>
  <li class="check"><span class="check">&#10003;</span> Found-bug fix #2
    (real portability bug, not sandbox): when the PHP
    <code>memory_limit</code> is unlimited (<code>-1</code>, the common
    CLI default), <code>metricToInt("-1")</code> is <code>-1</code>, so
    every "<code>usage &gt; limit * fill_factor</code>" memory check goes
    permanently true (and the matching "&lt;" loop guard permanently
    false). That made the indexer seal a new partition almost every
    document &mdash; so the on-disk archive's layout depended on
    <code>memory_limit</code>, which it must not (archives are portable).
    It also one-shotted FetchUrl's curl pump loop and made the fetcher
    think memory was always low. Fixed with one helper,
    <code>memoryCeiling($fill_factor)</code> in Utility.php (returns
    <code>PHP_INT_MAX</code> when the limit is unlimited), applied at
    every decision site: PartitionDocumentBundle, FetchUrl (x3), Fetcher,
    and the legacy IndexArchiveBundle. The log-only site in
    IndexDocumentBundle keeps printing the raw limit. With the fix the
    two Index tests pass here under <code>-1</code> too; full suite
    40893/40894 (the one miss, VersionManager restoreVersion, fails on
    plain HEAD too and touches none of this code &mdash; a separate
    sandbox-only flake).</li>
  <li class="check"><span class="check">&#10003;</span> Found-bug fix #3
    (real defect, surfaced by IconProcessorTest leaving a stray
    "<code>..txt</code>" file in the repo root when OCR is enabled):
    <code>ImageProcessor::saveTempFile</code> built its temp name with
    <code>... . " . $file_extension"</code> &mdash; the intended
    "<code>.</code>" separator was swallowed into a string literal, so
    names came out as "<code>&lt;hash&gt; . ico</code>" with embedded
    spaces (VideoProcessor's twin was already correct). That spaced path
    was then handed unquoted to <code>tesseract</code> in
    <code>ComputerVision::recognizeText</code>, so the shell split it and
    tesseract saw output base "<code>.</code>", writing
    "<code>.</code>"+"<code>.txt</code>" = "<code>..txt</code>" into the
    working folder. Fixed the separator (now
    "<code>.</code>"&nbsp;.&nbsp;<code>$file_extension</code>) and hardened
    the exec with <code>escapeshellarg</code> on all three path/lang
    arguments so no future path can spray files into cwd. Added
    <code>tempFileNameTestCase</code> (runs without tesseract) asserting
    the temp name has no spaces and ends with the given extension; it
    fails on the old code, passes on the new. Only reproduces where
    TESSERACT is defined, which is why the PHP&nbsp;8.3 sandbox never saw
    it.</li>
  <li class="check"><span class="check">&#10003;</span> Found-bug fix #4
    (real flaky defect in VersionManager, ~50% fail rate here, passes
    more often on the dev box purely by timing luck):
    <code>restoreVersionTestCase</code> failed about half the time because
    <code>getActiveVersion</code> could skip the exact version asked for.
    A version's folder is named by its timestamp turned into a string at
    PHP's default float precision, which rounds the last digit; the
    timestamp searched on is kept at full precision from the serialized
    record. So restoring a version to its own exact time could compare as
    just past that version and fall back to an older one (here the 1970
    "initial" version, timestamp 1), leaving the expected file missing.
    Fixed by rounding the incoming search value through the same string
    form the folder names use, in both <code>getActiveVersion</code> and
    (same latent boundary bug) <code>getVersionsInRange</code>. Proven:
    a 60x repeat loop went from ~half failing to 60/60, the test is now a
    steady [5/5], and the full unit suite is 40897/40897 all green.</li>
  <li class="q">Code audit (this patch, no behaviour change): inlined
    two single-use private helpers in <code>UnsubscribeToken</code>
    (<code>encode</code> into <code>makeEmail</code>,
    <code>decode</code> into <code>parseEmail</code>); merged the two
    near-identical signing methods (<code>sign</code> and
    <code>signDomain</code>, same body differing only by prefix) into
    one <code>signPayload($prefix, $payload)</code> with four callers;
    and removed the duplicated "one-click &rarr; act &rarr; done, else
    confirm" block in <code>ApiController::unsubscribe</code> by setting
    the state once after a single scope branch. Suite unchanged at
    40946/40946.</li>
</ol>

<h2>Out of this arc</h2>
<ol>
  <li>DKIM signing (RFC&nbsp;6376) &mdash; verified already done (see
    above), so the only item-12 mail piece left after this arc is
    documenting SPF / DMARC (DNS-side) in the install guide.</li>
</ol>

<h2>Design decisions (resolved with Chris)</h2>
<ol>
  <li>Thread + group level both honored. Group level is the
    <code>RECEIVE_MAIL</code> column. Thread level is supported at least
    at the header level, with an "unfollow" control on the thread page
    (Phase A item 4).</li>
  <li>Whole-domain suppression is a standalone table keyed by email
    address.</li>
  <li>Unsubscribe tokens are signed with <code>crawlAuthHash</code> so
    the URL and the mailto suffix cannot be forged.</li>
  <li><code>bot@&lt;domain&gt;</code> uses the domain the mail was going
    out on, resolved at enqueue time.</li>
  <li><span class="check">&#10003;</span> Decided in Phase B: the header
    is precomputed at enqueue and carried as <code>$extra_headers</code>
    through the send / queue / sweep path (see Phase B item 2).</li>
</ol>
</body>
</html>
X