<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Yioop v10 β Working TODO List</title>
<style>body{font-family:system-ui,sans-serif;max-width:820px;margin:1.5em auto;padding:0 1em;line-height:1.5;color:#1a1a1a}
h1{font-size:1.5em;border-bottom:2px solid #ddd;padding-bottom:.2em}
h2{font-size:1.15em;margin-top:1.6em;color:#24292f;border-bottom:1px solid #eee;padding-bottom:.15em}
ul{margin:.3em 0}li{margin:.35em 0}ul.items>li.item{list-style:none;margin-left:-1.1em}
.num{font-weight:600;color:#57606a}.check{color:#1a7f37;font-weight:bold}.box{color:#999}
.part{color:#b35900;font-weight:bold}.drop{color:#cf222e;font-weight:bold}del{color:#999}
code{background:#f2f2f2;padding:0 .25em;border-radius:3px;font-size:.92em}
pre{background:#f6f8fa;padding:.6em .8em;border-radius:5px;overflow:auto}pre code{background:none}
em{color:#555}strong{color:#1a1a1a}p{margin:.5em 0}</style>
</head>
<body>
<p><em>HTML edition, converted from Markdown 2026-06-19. Legend: <span class="check">✓</span> done <span class="box">☐</span> open <span class="part">▮</span> partial <span class="drop">✗</span> dropped.</em></p>
<h1>Yioop v10 β Working TODO List</h1>
<p>Last updated: 2026-07-13. Done items marked [x]. <strong>Phases 0–4
are complete</strong>; Phase 4.5 is in progress.</p>
<p><em>2026-06-15 progress pass (git log since 2026-06-13 13:30, through
HEAD <code>7bfab72</code>):</em> item 13 (outbound mail construction dedup) and
item 47 (random logouts β database-backed session restore) closed;
item 17 (page-resource folder UI) substantially landed (remainder
noted under the item). New production/startup work folded in as
items 69β71 and logged in Phase 2.5. No items removed.</p>
<h2>Atto integration (yioop now uses WebSite, MailSite, TurnSite from atto v2)</h2>
<ul class="items">
<li class="item"><p><span class="num">1.</span> <span class="box">☐</span> <strong>Use the new WebSocket support in <code>WebSite.php</code> for chat /
live updates.</strong> When Yioop is being served by atto's <code>WebSite</code>,
prefer WebSockets over SSE for the messaging refresh path. When
Yioop is running under Apache/nginx (no WebSocket support
guaranteed), fall back to SSE.</p></li>
<li class="item"><p><span class="num">2.</span> <span class="box">☐</span> <strong>Replace the polling loop on text chat (<code>messagesStatus</code> β
<code>type=status</code>, no active call) with push-based delivery.</strong>
Currently SSE is used only when a call is active; plain text
messaging still polls.</p></li>
</ul>
<h2>Voice/video calling</h2>
<ul class="items">
<li class="item"><p><span class="num">3.</span> <span class="box">☐</span> <strong>Investigate and fix bugs in the initial video-chat
connection establishment.</strong> Symptom: the call sometimes fails to
establish on the first try. Suspect signaling order between SSE
and the first ICE/SDP exchange in <code>messagesStatus</code> /
<code>addCallEvent</code> / <code>nextCallEvent</code>, or the call-group state machine
in <code>GroupModel</code>. Need a reproducer first.</p></li>
<li class="item"><p><span class="num">4.</span> <span class="box">☐</span> <strong>Stand up <code>TurnSite.php</code> (from atto) as a TURN/STUN server
for Yioop video chats.</strong> Wire the ICE-server config in
<code>Config.php</code> (~line 1493) to be served by TurnSite when enabled.</p></li>
<li class="item"><p><span class="num">5.</span> <span class="box">☐</span> <strong>Add Manage-Machines controls for TurnSite:</strong> on/off,
status, logging.</p></li>
</ul>
<h2>Chat UI refresh</h2>
<ul class="items">
<li class="item"><p><span class="num">6.</span> <span class="box">☐</span> <strong>Modernise the chat input row</strong> (<code>UsermessagesElement.php</code>
lines 366β406): wrap emoji + text + attach into a single rounded
"pill"; replace single-line <code><input type="text"></code> with
auto-growing multi-line <code><textarea></code> (Enter-to-send, Shift+Enter
for newline); swap mic/send based on whether input has text;
move file-input inside the pill.</p></li>
<li class="item"><p><span class="num">7.</span> <span class="box">☐</span> <strong>Move tertiary chat-header actions behind a kebab menu.</strong></p></li>
<li class="item"><p><span class="num">8.</span> <span class="box">☐</span> <strong>Rethink the translation feature.</strong> Per-conversation
translation dropdown is confusing UX. Either drop entirely or
replace with a per-message context-menu affordance (iMessage /
WhatsApp pattern).</p></li>
<li class="item"><p><span class="num">9.</span> <span class="box">☐</span> <strong>Bubble + spacing polish.</strong> Sender-coloured "me" vs neutral
"them" via the new CSS custom properties (item 24); time labels
only between groups (already done β keep it); quoted-reply
visual.</p></li>
</ul>
<h2>Mail</h2>
<ul class="items">
<li class="item"><p><span class="num">10.</span> <span class="check">✓</span> <strong>Add Manage-Machines controls for <code>MailSite.php</code>:</strong> on/off,
status, logging. <em>Wired in <code>MachineController::statuses</code> (the
<code>MailServer</code> case dispatches <code>CrawlDaemon::start</code> /
<code>CrawlDaemon::stop</code> based on the <code>start</code> / <code>stop</code> action) and
in <code>MachineController::log</code> (the <code>MailServer</code> case reads
<code>LOG_DIR/MailServer.log</code>; the <code>Mail</code> case reads
<code>LOG_DIR/<SmtpClient::MAIL_LOG_NAME></code> for the per-send mail
transcript). Status visibility is by membership in the
<code>$statuses["MailServer"][-1]</code> map.</em></p></li>
<li class="item"><p><span class="num">11.</span> <span class="check">✓</span> <strong>Webmail interface inside Yioop β built.</strong> A capability
Yioop did not have before v10: the <code>userMail</code> activity reads and
writes MailSite mail through the MailBackend abstraction β
folders, message list, read/compose/reply, flags, move/delete β
so the webmail surface is built and in use. MailSite exposes the
webmail-friendly direct-call API it uses. Further enhancements are
filed individually as concrete items rather than tracked as an
open-ended "polish" task (example: swipe-left-to-delete a message
on mobile).</p></li>
<li class="item"><p><span class="num">12.</span> <span class="check">✓</span> <strong>Fix bulk-mail headers so Gmail/Yahoo/etc. stop rejecting
<code>BulkEmailJob</code> output.</strong> Add Message-ID, MIME-Version,
Content-Type, Content-Transfer-Encoding, Reply-To, Return-Path,
and for bulk paths List-Unsubscribe + List-Unsubscribe-Post
(RFC 8058). Also DKIM signing (RFC 6376). SPF and DMARC are
DNS-side but document them in the install guide.</p></li>
<li class="item"><p><span class="num">13.</span> <span class="check">✓</span> <strong>Refactor outbound mail to share message-construction code
with MailSite where useful, but keep the SMTP-client role.</strong>
Plan: extract RFC 5322 / MIME building into a shared helper;
when local MailSite is enabled and the recipient is local,
route via <code>MailSite::deliverMail()</code>; shrink the remaining
outbound code to remote-relay only. <em>Status: partial. The
shared helper <code>library/MimeMessage.php</code> exists and is the
builder used by both <code>SmtpClient::sendImmediate</code> (the
legacy outbound path) and <code>userMailDispatchMailsiteSend</code>
(the new MailSite-outbound path from item 55). Local-
recipient routing via <code>MailSite::deliverMail()</code> is wired in
<code>userMailDispatchMailsiteSend</code>. Remaining work: trim
<code>SmtpClient</code> down to the pure remote-relay role now that
message construction lives in <code>MimeMessage</code> and local
delivery bypasses SMTP entirely; in particular,
<code>SmtpClient::sendImmediate</code>'s convenience overload that
still builds a multipart body from <code>to/subject/body/attachments</code>
args can collapse to a thin call into <code>MimeMessage::build</code>
plus the wire-protocol exchange.</em>
<em>Closed 2026-06-14 (commit <code>0e85328</code>, "mail construction dedup
and docs"): <code>SmtpClient::sendImmediate</code> now builds the wire
message via <code>MimeMessage::build(...)</code> and <code>SmtpClient</code> is trimmed
to the wire-protocol exchange; ~600 lines of duplicated multipart
construction removed across <code>MimeMessage.php</code>/<code>SmtpClient.php</code>, and
docblocks rewritten to plain English. Both outbound paths
(<code>sendImmediate</code> and <code>userMailDispatchMailsiteSend</code>) now share one
builder; local-recipient routing via <code>MailSite::deliverMail()</code> was
already wired (item 55). Item 12's header work (RFC 5322 / DKIM /
List-Unsubscribe) is separate and still open.</em></p></li>
<li class="item"><p><span class="num">14.</span> <span class="check">✓</span> <strong>Rename <code>library/MailServer.php</code> β
<code>library/SmtpClient.php</code>.</strong> Landed early as part of
phase 0 (the rename was inexpensive and removed a long-
standing misname -- the old name suggested a server-side
listener, but the class is purely a client of an upstream
SMTP server). Item 13 (refactor outbound to share
construction code with MailSite) still pending for phase
2.</p></li>
<li class="item"><p><span class="num">63.</span> <span class="check">✓</span> <strong>Configurable mail Banner (<code>MAIL_HOST_NAME</code>).</strong> New
Server-Settings field labelled "Banner:" that sets the host name
MailSite announces in the SMTP/IMAP greeting and uses as the
EHLO/HELO identity on outbound delivery. Single source of truth
<code>MailSiteFactory::mailHostName()</code> (= <code>MAIL_HOST_NAME</code>, else first
<code>MAIL_DOMAINS</code>, else <code>php_uname('n')</code>, else <code>localhost</code>);
<code>MailServer::serverName</code> and <code>SmtpClient</code>'s outbound HELO both
delegate to it (HELO is now one consistent server identity instead
of the per-message sender domain). <code>SystemComponent</code> folds a PTR
row (resolved sending IP β banner host) into the Suggested DNS
Records panel, and the field placeholder shows the computed
default. Wiki Mail Services page updated with a Banner section +
PTR note. Advances the item-12/36 deliverability story (the
HELO/PTR/FCrDNS half); RFC-5322/DKIM/List-Unsubscribe header work
in item 12 is still open.</p></li>
</ul>
<h2>Crawling</h2>
<ul class="items">
<li class="item"><p><span class="num">15.</span> <span class="box">☐</span> <strong>Diagnose and fix the "crawl/index speed degrades over
time" bug.</strong> Suspect Scheduler tier round-robin, QueueBundle /
Bloom-filter growth, IndexDocumentBundle partition rotation,
fetcherβqueue-server schedule round-trip cost as bundles get
bigger. Need instrumentation first. Sub-items:
a. <strong>Synthetic crawl test driver.</strong> Build a test mode that
simulates large crawls under realistic queue-server /
fetcher / name-server pressure without actually fetching
from the web. Synthetic URLs are minted from a seeded
deterministic generator; <code>Fetcher</code> swaps in a mock
<code>getPages()</code> that returns synthetic pages with controllable
size, fan-out, and content distributions; the rest of the
pipeline (parsing, scheduling, indexing, distribution to
queue servers, summary upload) runs unmodified. Lets us
drive a multi-million-page crawl on a single workstation
in minutes and observe where time and memory accumulate.
Wire as a config flag (<code>SYNTHETIC_CRAWL_MODE</code>) plus a
test executable in <code>executables/</code> that orchestrates the
synthetic name-server + N synthetic fetchers.
b. <strong>Instrumentation.</strong> Add per-stage timing and memory
counters to the suspect paths (Scheduler tier walk,
QueueBundle add/peek, dictionary update, partition flush,
fetcher-to-queue-server upload). Emit to a structured log
(CSV or JSON-lines) the synthetic flow can post-process.
c. <strong>Diagnose and fix.</strong> Run (a) with (b), look at where
per-page wall time starts climbing, fix what's found.</p></li>
<li class="item"><p><span class="num">73.</span> <span class="box">☐</span> <strong>Crash-safe index writes and quick recovery from a
corrupt index.</strong> This is the root-cause fix behind the Phase 4
out-of-memory investigation: a corrupt on-disk index made
<code>getPostingsString</code> read garbage-length postings, which on the
live server pushed memory over the limit. Phase 4 shipped the
mitigations that keep a corrupt index from taking the running
server down (a maximum readable posting length that skips
oversized reads, memory-scaled index caches, and per-cache and
max-posting-read instrumentation), but those are guards, not
prevention. The prevention is here. Sub-items:
a. <strong>Audit the index-bundle write path.</strong> Walk how
<code>IndexDocumentBundle</code> is added to during a crawl (partition
append, dictionary merge, postings write) and find every point
where an interrupted or partial write could leave a half-written
entry that a later read interprets as a garbage length or offset.
b. <strong>Make the writes crash-safe.</strong> Ensure that if the queue
server or dictionary process crashes, is interrupted with Ctrl-C,
or is killed partway through, the index is never left corrupt:
write-then-rename or a small journal/marker so a partially applied
step is either completed or rolled back on restart, giving a quick,
safe recovery to a known-good point rather than a corrupt bundle.
c. <strong>Reproduce and verify with the synthetic crawl driver</strong>
(item 15a): drive a crawl, kill the queue server or dictionary
process at chosen points in the write path, and confirm the index
comes back clean and readable every time.</p></li>
</ul>
<h2>Wiki</h2>
<ul class="items">
<li class="item"><p><span class="num">16.</span> <span class="check">✓</span> <strong>Audit and rewrite the wiki-page parsing regexes.</strong>
Current regexes are fragile and have potential security flaws
(XSS via crafted markup, ReDoS). Build an adversarial test
corpus and either harden the regexes or move to a proper
tokenizer/parser. Overlaps with the security audit (item 27).
Decided on the tokenizer and parser, written in both PHP
(WikiParser) and JS (help.js), covering the mediawiki-style and
markdown engines, with a new markdown help page. A golden
regression corpus is in place; the PHP parser and the JavaScript
help.js port are both done, the port covered by per-case
JavaScript unit tests, and the old regex engine is gone. The
adversarial probing found the nesting failures real but the XSS
and ReDoS not reproducible with direct probes. Completed in
Phase 4, closed 2026-07-10; see the Phase 4 arc plan.</p></li>
<li class="item"><p><span class="num">17.</span> <span class="box">☐</span> <strong>Modernise the wiki page-resource folder UI</strong>
(<code>WikiElement::renderResources</code> line 969 / <code>renderResource</code>
line 1296; drag-drop in <code>basic.js</code> line 973):</p>
<ul>
<li>
<p>Replace the fixed-<code>in</code>-width table with CSS Grid using
<code>repeat(auto-fill, minmax(min, 1fr))</code> and a container query.</p>
</li>
<li>
<p>Add direct delete (ποΈ) per row with confirm dialog. Keep
clipboard for cross-page moves.</p>
</li>
<li>
<p>Replace text action buttons with icons. Keep <code>tl()</code> strings
as <code>aria-label</code> + <code>title</code>.</p>
</li>
<li>
<p>Inline rename: click name to edit, Enter / blur to commit,
Esc to cancel.</p>
</li>
<li>
<p>Drag-drop between same-page subfolders (folder rows +
up-level breadcrumb become drop targets).</p>
</li>
<li>
<p>Visible drop-zone overlay when files are dragged from the OS.</p>
</li>
<li>Multi-select with Shift / Ctrl-click; keyboard shortcuts
(Delete, F2, Ctrl+X / C / V, Esc).
<em>Status 2026-06-15: substantially landed across the resource
drag-drop / mail-style action arc (commits <code>8f43299</code>β¦<code>7bfab72</code>).
Done: per-row direct delete with confirm; text action buttons
replaced by a Mail-style toolbar (β confirm-rename / β delete /
β― more-menu with Clip Copy + Clip Cut, plus β wiki-code and
Extract), <code>tl()</code> strings kept as label/title; inline rename
(click name, Enter/blur commit, Esc cancel); drag-drop between
same-page subfolders with the folder rows and a pinned Parent
Folder row (incl. move-to-root) as drop targets; OS-file drop
onto a folder with drop handling; clipboard retained for
cross-page moves; multifile and whole-folder drag-drop upload
(multifile <code>$_FILES</code> in <code>WebSite</code>, single clean version per
folder via the file's own <code>headPutContents</code>), with an upload
progress readout (transferred size + moving-average speed) and
H2 inbound upload-window sizing; the VersionManager
stranded-lock root-cause bug fixed (<code>copyFileToGroupPageResource</code>
passed <code>$timestamp</code> into the <code>$lock</code> slot, leaving a lock that
silently failed every later rename/version op β commit <code>bff69c9</code>);
the edit controls render identically under Apache and the
built-in server (the v10-introduced <code>is_writable()</code> UI gate was
the offender; resolved via work_directory permissions, the patch
not needed). Remaining: multi-select (Shift/Ctrl-click) +
keyboard shortcuts (Delete/F2/Ctrl+X/C/V/Esc); the fixed-<code>in</code>-
width table β CSS-Grid <code>auto-fill</code>/container-query rewrite (the
grid layout exists but is not the auto-fill rewrite). Keep open
for those two. Logged in Phase 2.5.</em></li>
</ul></li>
<li class="item"><p><span class="num">18.</span> <span class="box">☐</span> <strong>Flesh out the <code>{{category|...}}</code> / <code>{{category-list|...}}</code>
mechanism.</strong> Named parameters; shipped style presets
(<code>style=list|cards|grid|hero|ticker</code>); pagination; auto-stub
category index pages; "Filed under" affordance via <code>show</code>
flag; cross-group lookup; replace custom <code><x-c-list></code> tags
with semantic HTML.</p></li>
<li class="item"><p><span class="num">19.</span> <span class="box">☐</span> <strong>Add a wiki editor live preview pane.</strong> Split the editor:
textarea on the left, server-rendered preview on the right,
AJAX refresh on debounce. Renderer is the existing
<code>WikiParser</code>. ~50 lines of JS, no deps.</p></li>
<li class="item"><p><span class="num">20.</span> <span class="box">☐</span> <strong>Add a contenteditable WYSIWYG-lite layer over the
textarea</strong> (incremental, after item 19). Toolbar buttons insert
wiki markup at cursor β MediaWiki classic-toolbar approach
scaled down. Textarea remains source of truth. ~500β1500 lines,
no deps.</p></li>
<li class="item"><p><span class="num">21.</span> <span class="box">☐</span> <strong>Asynchronous email-based witness flow for secret
ballots.</strong> The existing <code>{{secret-ballot|w1|w2|...}}</code> mechanism
(<code>SocialComponent::initializeBallot</code> line 5176;
<code>SigninModel::createWitnessKeyPair</code> line 385,
<code>createBallotFile</code> line 253, <code>countBallotFile</code> line 289)
requires all witnesses to authenticate on the same form to
initialise the ballot and again to count it. Allow each
witness to participate via emailed signed links instead, while
preserving the current security and anonymity properties β the
server must never see witness passwords or be able to
reconstruct the private key without witness collaboration.
Approach (no new deps; uses sodium + hash_hmac that are
already in use):</p>
<ul>
<li>
<p>On ballot init, server emails each witness a one-shot link
with a token = HMAC-SHA256(page_id|ballot_id|witness_id|
stage|expiry|nonce) keyed by <code>AUTH_KEY</code>. Click + sign-in +
witness password generates that witness's <code>$random_i</code> and
<code>$hash_i</code> per the existing <code>createWitnessKeyPair</code> formula.</p>
</li>
<li>
<p>To preserve the "server never sees the hashes" property:
each witness derives a per-ballot keypair from their
password (e.g. <code>sodium_crypto_box_seed_keypair</code> of
<code>hash($password β
$witness β
$ballot_id)</code>) and encrypts
their <code>$hash_i</code> to themselves. Server stores opaque blobs.</p>
</li>
<li>
<p>Once all <code>n</code> witness blobs are present, server can publish
the ballot's public key only after one final witness
confirmation step that releases an aggregated hash chain
(avoiding needing every witness on a single live form, but
still requiring all to have acted). Several variants of
this last step are possible; the simplest: the <em>initiator</em>
witness collects the blobs in a final email-link round and
computes the polynomial.</p>
</li>
<li>
<p>Count time uses the same email-link mechanism in reverse:
each witness clicks a second emailed link, signs in,
submits their password to decrypt their own blob; once all
contribute, server reconstructs the private key as today.</p>
</li>
<li>
<p>Threat model unchanged from the current synchronous flow:
requires all named witnesses to act; server never sees
passwords or pre-aggregation hashes; voter anonymity
identical (votes still encrypted with sealed-box to the
ballot public key).</p>
</li>
<li>
<p>Tests must cover: token expiry, replay rejection, witness
who never responds, witness password mismatch on second
click, key-mismatch detection (the existing
<code>KEY_MISMATCH</code> path).</p>
</li>
</ul></li>
</ul>
<h2>MediaUpdater / MediaJob distribution</h2>
<ul class="items">
<li class="item"><p><span class="num">22.</span> <span class="box">☐</span> <strong>Make more <code>MediaJob</code> subclasses distributable.</strong> Survey:</p>
<ul>
<li>
<p>Distributable today: <code>BulkEmailJob</code>, <code>FeedsUpdateJob</code>,
<code>VideoConvertJob</code> (full), <code>AudioTranscriptionJob</code> (partial).</p>
</li>
<li>
<p>Name-server-only today: <code>PodcastDownloadJob</code>,
<code>WikiThumbDetailJob</code>, <code>DescriptionUpdateJob</code>,
<code>AnalyticsJob</code>, <code>CullOldRawImpressionsJob</code>,
<code>RecommendationJob</code>, <code>TrendingHighlightsJob</code>.</p>
</li>
</ul></li>
</ul>
<h2>Mobile detection / responsive layout</h2>
<ul class="items">
<li class="item"><p><span class="num">23.</span> <span class="box">☐</span> <strong>Migrate <code>if ($_SERVER["MOBILE"])</code> branches into CSS
media queries.</strong> ~30 PHP files currently branch on this for
layout. Use <code>@media (max-width: β¦)</code> and <code>@container</code> (Baseline
2023). File-by-file refactor.</p></li>
<li class="item"><p><span class="num">24.</span> <span class="drop">✗</span> <strong><del>Replace the UA string sniff with UA Client
Hints.</del></strong> <em>Dropped 2026-06-19.</em> Client Hints
(<code>Sec-CH-UA-Mobile</code>) are Chrome-only β Safari and Firefox do not
send them, so the UA-string sniff would have stayed as the
fallback anyway, leaving two mechanisms where there was one. And
items 23 and 25 move the mobile decisions into CSS and emit the
viewport unconditionally, removing most of the server-side need.
A one-line UA fallback stays. (The earlier narrow sub-task of
dropping <code>fennec</code> from the UA sniff was already done β commit
<code>c590f5d</code>.)</p></li>
<li class="item"><p><span class="num">25.</span> <span class="box">☐</span> <strong>Emit the viewport meta tag unconditionally</strong> β moved
from Phase 0 to Phase 5. Was reverted because the surrounding
CSS isn't yet flex-friendly: with <code>width=device-width</code>, the
desktop layout extends past phone viewports and content gets
cut off. Lands together with items 23, 26, 27, 29, 30, 31 so
the layout actually adapts when the meta tag is honoured.</p></li>
</ul>
<h2>CSS modernisation (also a Coding.pdf update β see item 36)</h2>
<ul class="items">
<li class="item"><p><span class="num">26.</span> <span class="box">☐</span> <strong>CSS custom properties for color, spacing, typography.</strong>
<code>:root { --color-fg, --color-bg, --color-accent, --space-1 β¦
--space-6, --font-size-base }</code>. Foundational β items 9, 17,
18 consume these.</p></li>
<li class="item"><p><span class="num">27.</span> <span class="box">☐</span> <strong>Adopt CSS logical properties</strong> (<code>margin-inline-start</code>,
<code>padding-block</code>, <code>inset-inline-end</code>, β¦) to replace the
duplicated <code>.html-ltr</code> / <code>.html-rtl</code> selector pattern (106
duplications in <code>search.css</code> alone). Baseline since 2023.
<em>Note: the mail message-action row introduced this pattern as
a first foothold during the Phase 2 work β <code>.mail-message-
actions-trash-group</code> uses <code>border-inline-start</code> /
<code>padding-inline-start</code> / <code>margin-inline-start</code> instead of
paired <code>.rtl</code> overrides. Use as the reference for the wider
sweep.</em></p></li>
<li class="item"><p><span class="num">28.</span> <span class="box">☐</span> <strong>Built-in dark mode via <code>color-scheme: light dark</code>,
<code>light-dark()</code>, <code>prefers-color-scheme</code>.</strong></p></li>
<li class="item"><p><span class="num">29.</span> <span class="box">☐</span> <strong>Drop physical-page units (<code>in</code>, <code>pt</code>) from screen CSS</strong>
in favour of <code>rem</code> / <code>em</code> / <code>%</code> / <code>ch</code> / <code>vw</code> and <code>clamp()</code>.
<code>search.css</code> currently has 276 <code>in</code>, 127 <code>pt</code>, 784 <code>px</code>, 4
<code>rem</code>/<code>em</code>.</p></li>
<li class="item"><p><span class="num">30.</span> <span class="box">☐</span> <strong>Accessibility-aware media features:</strong>
<code>prefers-reduced-motion</code>, <code>prefers-contrast</code>,
<code>@media (hover: hover)</code>, <code>:focus-visible</code>. Verify WCAG 2.2 AA
contrast in both themes; add axe-core or Lighthouse alongside
the existing WAVE check.</p></li>
</ul>
<h2>Security audit & hardening</h2>
<ul class="items">
<li class="item"><p><span class="num">31.</span> <span class="box">☐</span> <strong>Run a full security audit before v10 release.</strong></p>
<ul>
<li>
<p><strong>Crypto.</strong> <code>crawlHash</code> (<code>Utility.php</code> line 1616) is <code>md5</code>-
with-halves-XOR'd β 8 bytes (or ~12-char base64). It's wired
into binary index formats β doc IDs, term IDs, posting
lists, B+-tree dictionary keys β and 164 callsites across
the tree, so a blanket replacement would break every
existing index and quadruple storage. Add a sibling
<code>crawlAuthHash($string)</code> returning
<code>hash_hmac('sha256', $string, AUTH_KEY)</code> for security uses;
migrate just the security-relevant callsites to it
(CSRF token in <code>Controller::generateCSRFToken</code> line 649 +
check at line 671; the <code>crawlHash("A")</code> length-test at line
914 needs the new function's length; audit the rest).
Indexing callsites stay on <code>crawlHash</code> unchanged.</p>
</li>
<li>
<p><strong>Constant-time comparisons.</strong> <code>checkCSRFToken</code>
(<code>Controller.php</code> line 672) uses <code>==</code>. Switch to
<code>hash_equals</code>.</p>
</li>
<li>
<p><strong><code>crawlCrypt</code> fallbacks.</strong> Remove <code>mcrypt_create_iv</code> and
<code>mt_rand</code> branches once PHP floor lands at β₯ 8.0.</p>
</li>
<li>
<p><strong>Session cookies.</strong> Verify <code>HttpOnly</code>, <code>Secure</code>,
<code>SameSite</code>; add CSP and <code>X-Content-Type-Options: nosniff</code>.</p>
</li>
<li>
<p><strong>I/O paths.</strong> Spot-check <code>$_REQUEST</code> for
<code>Controller::clean()</code>; view echoes for escaping; file
uploads for traversal / MIME / executable.</p>
</li>
<li>
<p><strong>SQL.</strong> Verify all models use prepared statements.</p>
</li>
<li><strong>Wiki regex parser.</strong> Item 16; correctness and ReDoS.</li>
<li><strong>Resource controllers.</strong> Path traversal.</li>
<li>
<p><strong>CSRF coverage.</strong> Every state-changing action calls
<code>checkCSRFToken</code>.</p>
</li>
<li>
<p><strong>Dependency surface.</strong> <code>composer.lock</code> and bundled JS/CSS
for known CVEs.</p>
</li>
</ul></li>
<li class="item"><p><span class="num">32.</span> <span class="check">✓</span> <strong>Retire the image captcha; gate signup with proof-of-work plus cheap invisible checks and an email confirmation.</strong> Image captchas are a declining defence — cheap machine solvers and human captcha-farms beat them — and a burden for blind users, so the <code>IMAGE_CAPTCHA</code> mode and <code>CaptchaModel</code>'s image drawing are removed. In its place, all self-contained (no outside service), layered:</p>
<ol>
<li><strong>Proof-of-work as the gate.</strong> Keep and strengthen the existing <code>HASH_CAPTCHA</code> mode: the browser runs <code>hash_captcha.js</code> / <code>sha1.js</code> to find a nonce that meets a difficulty level, so a real signup waits a fraction of a second while a bot pays that cost on every one of thousands of attempts. Tune the difficulty (<code>HASH_CAPTCHA_LEVEL</code>), make it the single captcha, and simplify the admin Captcha-Type control.</li>
<li><strong>Cheap invisible checks</strong> that cost a bot but never bother a person: a honeypot field, a submit-timing check (reject a form completed implausibly fast), and per-IP / per-session rate limits on signup and recovery. The honeypot <strong>must be hidden from assistive technology</strong> (screen readers such as JAWS / NVDA / VoiceOver): hidden from view, out of the tab order (<code>tabindex="-1"</code>), <code>aria-hidden="true"</code>, and <code>autocomplete="off"</code>, so a blind user never reaches it or is wrongly flagged for filling it.</li>
<li><strong>Email confirmation as the real gate.</strong> Require a confirmed email address before an account activates, so every fake account needs a fresh working mailbox. Rides on Yioop's own mail and dovetails with the item 33 email work.</li>
</ol>
<p>Touch points (verified 2026-06-19): the auth / suggest mode (global <code>C\CAPTCHA_MODE</code>) lives in <code>RegisterController</code> (the <code>switch (C\CAPTCHA_MODE)</code> branches in create / recover / resend / suggest), <code>SystemComponent</code> (the <code>CAPTCHA_MODES</code> options), <code>Config.php</code> (<code>IMAGE_CAPTCHA</code> constant), <code>SecurityElement</code> (Captcha-Type control), and the auth forms (<code>RegisterView</code>, <code>RecoverView</code>, <code>ResendEmailView</code>, <code>SuggestView</code>). Separately, the image captcha has a second life as the embeddable wiki <code>[{image-captcha}]</code> placeholder — <code>setupGraphicalCaptchaViewData</code> in the base <code>Controller.php</code>, the <code>user_captcha_text</code> / <code>captcha_text</code> verification in <code>SocialComponent</code>, plus <code>StaticController</code>, <code>WikiElement</code>, <code>StaticView</code> — which is not gated by the mode and needs its own decision (drop / replace with proof-of-work / keep image only there). <code>CaptchaModel</code> is removed only if that placeholder is dropped or replaced. <em>Repointed 2026-06-19: was “modernise the image captcha”; Chris approved retiring the image method for proof-of-work + invisible layers + email gate, all self-contained.</em></p></li>
<li class="item"><p><span class="num">33.</span> <span class="check">✓</span> <strong>Replace the recovery-question system.</strong> (a) Default new
installs to <code>EMAIL_RECOVERY</code> (done). (b) Add email-based
one-time login codes: a short, time-limited, single-use code (or
signed link) mailed to the member's registered address, so they
can sign in or recover without a saved password and without any
external authenticator app. Yioop already sends mail, so this
adds no outside dependency β it is the same kind of email
one-time login Chris uses with other services. (c) For an
offline / airgapped install that keeps a question mode, let users
type their own question and answer; drop the
<code>register_view_recoveryN_*</code> translation infrastructure and remove
the existing <code>EMAIL_AND_QUESTIONS_RECOVERY</code> mode.
<em>(Dropped 2026-06-19: TOTP and printed single-use recovery codes.
TOTP forces members to install a separate authenticator app,
which cuts against Yioop staying self-contained; the printed
codes were its sidekick. The email one-time code in (b) is the
self-contained replacement.)</em></p></li>
<li class="item"><p><span class="num">47.</span> <span class="check">✓</span> <strong>Stop random logouts at the environment level β deeper
session-lifetime fix.</strong> The phase-0 <code>ini_set</code> hot-fix in
<code>src/index.php</code> raises <code>session.gc_maxlifetime</code> /
<code>session.cookie_lifetime</code> per request, but does not reach
Debian / Ubuntu's <code>/etc/cron.d/php*</code> job. That cron sweeps
<code>session.save_path</code> (default <code>/var/lib/php/sessionsXY</code> or
<code>/tmp</code>) every 30 minutes using the value of <code>gc_maxlifetime</code>
from <code>/etc/php/X.Y/cli/php.ini</code>, which is whatever the
distro shipped β independent of what PHP-FPM / Apache sets
per process. On at least one Yioop deployment this still
causes randomly-timed logouts; the symptom is "I came back
after lunch and was logged out" even though <code>AUTOLOGOUT</code>
is much longer than the lunch.</p>
<p>Pick one of the following, in increasing order of robustness:</p>
<p>(a) <strong>Move session storage out of <code>/tmp</code> / <code>/var/lib/php</code>.</strong>
Set <code>session.save_path</code> to <code>WORK_DIRECTORY/data/sessions</code>
(chmod 700) before <code>session_start()</code>. The Debian cron job
only sweeps its configured <code>save_path</code>; a Yioop-owned
directory is invisible to it. This is the smallest change
and probably fixes the observed deployment immediately.
Document the directory in the INSTALL guide. ~10 lines in
<code>src/index.php</code> + a <code>mkdir -p</code> in the installer.</p>
<p>(b) <strong>Custom file-based session handler</strong>
(<code>SessionHandlerInterface</code>) that stores in
<code>WORK_DIRECTORY/data/sessions</code> and respects <code>AUTOLOGOUT</code> for
its own GC. Strictly stronger than (a) β Yioop owns the entire
lifecycle and no external process can delete files
prematurely β but adds ~60 lines and a unit test.</p>
<p>(c) <strong>Database-backed session handler</strong> storing into the
existing Yioop SQL store (new <code>USER_SESSIONS</code> table:
<code>SESSION_ID PK</code>, <code>USER_ID</code>, <code>DATA TEXT</code>, <code>LAST_ACCESS INT</code>,
<code>EXPIRES INT</code>). Necessary for multi-machine / load-balanced
deployments where local-disk sessions don't follow the user
across servers. Adds row-level invalidation on logout, an
admin "log out everyone" button, and a clean place to track
sessions per user for re-authentication prompts and suspicious-activity
alerts.</p>
<p>Recommended sequencing: do (a) first (small change, likely
fixes the reported deployment), then (c) as part of the
broader auth work in items 32-35 (item 33's email-one-time-login
work will want a per-session store anyway).
Skip (b) unless (a) doesn't resolve it and (c) is too big
for the v10 window.</p>
<p><em>Closed 2026-06-14 via approach (c), the robust
database-backed path (commits <code>9b4c27d</code>, <code>492ae95</code>, <code>ac1b7a1</code>,
<code>ff39001</code>β<code>e1f41a1</code>, <code>fcc1dd5</code>, <code>4d0f71d</code>, <code>d79e387</code>). New
<code>SESSION_USER</code> table (<code>SESSION_ID</code> PK / <code>USER_ID</code> / <code>EXPIRES</code>),
<code>SESSION_ID_LEN</code>=64, <code>DATABASE_VERSION</code> 104β105 with
<code>upgradeDatabaseVersion105</code>. The long-running atto <code>WebSite</code>
keeps live sessions in an in-memory LRU heap (<code>SplHeap</code>,
rebuilt on restart) and rehydrates a dropped/expired-from-memory
session from the <code>SESSION_USER</code> row, so a person stays signed in
across a server restart or memory eviction β the actual cause of
the "logged out after lunch" reports under the long-lived server.
Rehydrate is wired lazily in <code>index.php</code> / <code>Controller</code> /
<code>AdminController</code>, persistence via new <code>UserModel</code> session methods
+ <code>ProfileModel</code>; sequence-restore is tolerant of a missing/stale
row. This also gives the per-user session store items 32β35 want
(re-authentication prompts, "log out everyone"). The lighter (a)
<code>session.save_path</code> move is unnecessary now that Yioop owns the
session lifecycle through the DB store, but is still worth a line
in the INSTALL guide for Apache/php-fpm deployments where the
distro cron sweeps <code>/var/lib/php</code>.</em></p></li>
</ul>
<h2>Documentation (to be regenerated for v10)</h2>
<ul class="items">
<li class="item"><p><span class="num">34.</span> <span class="box">☐</span> <strong>Update the online wiki documentation</strong> (last full update
was for v9.3, Dec 2022). Source PDFs to refresh:
<code>yioop_docs.pdf</code>, <code>Ranking_Mechanisms.pdf</code>,
<code>Yioop_Install_Guides.pdf</code>, <code>Yioop_Wiki_Syntax.pdf</code>,
<code>Coding.pdf</code>.</p></li>
<li class="item"><p><span class="num">35.</span> <span class="box">☐</span> <strong>Document the new auth flows</strong> (email one-time login codes; the recovery modes and user-typed recovery question; and the hardened account-registration / activation-mail flow — opaque self-expiring activation token with no address in the link, generic anti-enumeration replies on the register and resend forms, per-IP and per-email resend throttling, a <code>Reply-To</code> from <code>MAIL_REPLY_TO</code>, and the multipart text + HTML activation message). Wiki source is delivered as a <code>.txt</code> for the wiki UI, never edited into the autogenerated help pages directly. <em>Moved out of the Phase 3 arc 2026-06-25 into this documentation push; the behaviour has shipped, only the write-up remains.</em></p></li>
<li class="item"><p><span class="num">36.</span> <span class="box">☐</span> <strong>Document mail deliverability requirements</strong> (DKIM,
SPF, DMARC, and now the <strong>Banner</strong>/<code>MAIL_HOST_NAME</code> host identity
+ PTR / forward-confirmed reverse DNS β see item 63). <em>The Mail
Services wiki page already gained a Banner section and a PTR note
in Suggested DNS Records this session; the PDF deliverability docs
still need the same. Moved 2026-06-19 into the final v10
documentation push (Phase 10); it documents standards already
shipped.</em></p></li>
<li class="item"><p><span class="num">37.</span> <span class="box">☐</span> <strong>Document the wiki resource-folder UI</strong>, the new
category-list parameters and styles, the live preview /
WYSIWYG modes, and the asynchronous secret-ballot witness
flow.</p></li>
<li class="item"><p><span class="num">38.</span> <span class="check">✓</span> <strong>Determined the exact current Yioop runtime requirements
for v10 release.</strong> PHP minimum pinned at <strong>8.1</strong>. Reconciled
<code>composer.json</code> (now <code>>=8.1.0</code> with <code>ext-curl</code>, <code>ext-intl</code>,
<code>ext-fileinfo</code>, <code>ext-libxml</code> required; <code>pdo_mysql</code> / <code>pdo_pgsql</code>
moved to <code>suggest</code>), <code>INSTALL.txt</code> (copyright bumped, dedicated
Requirements section added), <code>SystemComponent.php</code> runtime
check (<code>PHP_VERSION_ID < 80100</code>), and 25 locale
<code>system_component_php_version</code> strings. 6 pre-existing locale
translation bugs (de, el_GR, he, ko, pt, th) discovered and
fixed in passing. Commit <code>a1cd8f5</code>. <em>(Update 2026-06-10:
<code>ext-openssl</code> re-added to <code>composer.json</code> and <code>INSTALL.txt</code> β
the mail/TLS work, STARTTLS, DKIM signing, and ACME all depend
on it again, so it is a hard runtime requirement once more.)</em></p></li>
<li class="item"><p><span class="num">39.</span> <span class="check">✓</span> <strong>Updated the Coding-guidelines wiki page for v10.</strong>
Surgical scope per the agreed plan; details in Phase 1 block
below.</p></li>
</ul>
<h2>Mail backend / MailSite integration (Phase 2 incremental items)</h2>
<ul class="items">
<li class="item"><p><span class="num">48.</span> <span class="check">✓</span> <strong>Mail import CLI tool: migrate existing user mailboxes
into MailSite storage.</strong> Use case: an existing Postfix +
maildrop + dovecot stack delivers mail to per-user
filesystem trees (Maildir under
<code>/var/mail/<user>/Maildir/{cur,new,tmp}</code> or mbox under
<code>/var/mail/<user></code>). When a deployment switches to MailSite
for IMAP service, existing mail needs to migrate without
loss. Build <code>src/executables/MailImport.php</code> with sources
Maildir (priority), mbox, and optional live-IMAP. Common
pipeline: parse RFC 5322 bytes + flags + internal date, call
<code>MailStorage::appendMessage($user, $folder, $bytes, $flags,
$internal_date)</code>. Add <code>--dry-run</code> and <code>--resume</code> (Message-ID
dedup) flags. Default destination: <code>FileMailStorage</code> rooted
at <code>WORK_DIRECTORY/mail</code>. Unit-test against a sample Maildir
tree under <code>tests/test_files/</code> and an mbox sample. <em>No longer
blocking step 5; can be sequenced
independently now that the webmail surface works against
delivered mail.</em></p>
<p>*Status (updated 2026-06-07): substantially advanced; used in
production to migrate the pollett.org <code>cpollett</code> mailbox.
The tool is <code>src/executables/MailTool.php</code> (subcommand
dispatch: <code>import</code>/<code>verify</code>/<code>reuid</code>/<code>cleanup</code>, alongside the
earlier <code>migrate</code>). Maildir source landed earlier (see the
historical note below). This session's import work:</p>
<ul>
<li>
<p><strong>Streaming, memory-flat import.</strong> An earlier version built
an array of all message descriptors up front, which OOM'd on
the real ~49k-message run. Replaced with a streaming
<code>eachMessage</code> generator + lightweight <code>countMessages</code>; the
<code>appendMessage</code> loop is itself memory-flat (verified a 64M
limit holds across thousands of appends). The earlier partial
crash could not auto-resume (it had written no journal), so
the chosen recovery was wipe-and-reimport; the streaming
path completed it cleanly.</p>
</li>
<li>
<p><strong>Resumable + idempotent.</strong> Per-folder
<code>import-<folder>.journal</code> records done source basenames,
written before the mutation and kept after success, so a
re-run skips already-imported messages rather than
duplicating them, and a crash mid-run resumes where it left
off. <code>reportImport</code> distinguishes copied vs skipped.</p>
</li>
<li>
<p><strong>Per-folder subscribe + flags-snapshot priming.</strong> Import now
subscribes each created folder (storage-layer <code>createFolder</code>
does not subscribe the way IMAP CREATE does, and LSUB filters
to subscribed folders, so Apple Mail would not show
unsubscribed folders) and primes the flags snapshot via a new
public <code>refreshFlagsSnapshot</code>. Verified a from-empty rebuild:
an emptied user is fully reconstructed by import per folder.</p>
</li>
<li>
<p><strong>Per-folder UIDs from the start.</strong> Fresh imports allocate
per-folder UIDs (clients render UIDNEXT-1 as an estimated
message count, so a shared global UID space made Apple show
~143,931 messages); no separate <code>reuid</code> pass is needed after
a fresh import.</p>
</li>
<li>
<p><strong>Progress + timestamps.</strong> A "Scanning..." line plus
"copied N/total" every 1000, and an <code>outAt()</code> helper that
prefixes <code>[HH:MM:SS]</code> on progress/milestone lines across
import/reuid/cleanup/verify/migrate.</p>
</li>
<li>
<p><strong>New <code>reuid</code> subcommand.</strong> Renumbers a folder's UIDs into a
fresh per-folder space with a new UIDVALIDITY; crash-safe and
resumable via a <code>reuid.journal</code>. For repairing mailboxes that
predate per-folder UID allocation.</p>
</li>
<li>
<p><strong>New <code>cleanup</code> subcommand.</strong> Removes orphaned
<code><digits>.flags</code>/<code><digits>.date</code> companion files left by the
pre-consolidation storage format (one <code>.eml</code> per message now;
flags live in the index, with <code>flags.snapshot</code> as the
compaction-time recovery fallback). Streams <code>readdir</code>, leaves
<code>.eml</code>/metadata/subdirs, idempotent.
Still open: mbox source (next slice) and optional live-IMAP
source (deferred). Possible nicety: a one-command "import whole
dovecot tree" wrapper, and extending <code>cleanup</code> to sweep stale
import journals.*</p>
</li>
</ul>
<p>Status (updated 2026-06-10): <strong>closed.</strong> mbox source landed
on the import side (a file source is read as an mbox, split on
its "From " postmark lines, <code>>From</code>-unquoted, with flags from
Status/X-Status and the internal date from the Date header or
postmark), and the tool was trimmed to its durable command set.
The pre-production-only subcommands <code>migrate</code>, <code>reuid</code>, and
<code>cleanup</code> were <strong>removed</strong> β <code>migrate</code> converted a legacy
percent-encoded folder layout, <code>reuid</code> repaired the old global
UID space, and <code>cleanup</code> swept the orphaned <code>.flags</code>/<code>.date</code>
companion files; all three addressed one-time pre-production
states that no current or future install reaches, so they were
deleted along with their <code>moveDirFiles</code>/<code>cleanupFolder</code> helpers
rather than carried as dead maintenance surface. A new
<strong><code>export <user> <folder> <out_file></code></strong> subcommand writes a
user's local mail folder to an mbox: each message gets a "From "
postmark dated from its IMAP internal date, Status/X-Status
headers carrying its stored flags (which live in the folder
index, not the message bytes, so they must be written back for a
later import to recover them), and ">From " quoting of body
lines beginning "From "; the folder is streamed a message at a
time so a large folder stays memory-flat. The postmark is
written in the configured local timezone so it round-trips
through the import side's <code>strtotime</code> parse (a UTC-written
postmark was read back as local, shifting dates). Round-trip
verified end to end: export then re-import preserves message
count, flags, internal dates, and <code>>From</code> body lines exactly.
The command set is now <code>import</code>/<code>export</code>/<code>verify</code>. Delivered as
<code>yioop-mailtool-export-remove-commands.patch</code>.</p>
<p>Earlier status (Maildir slice): New
<code>src/library/MaildirSource.php</code> parser, <code>MailImport.php</code> CLI
skeleton with subcommand dispatch (<code>maildir <path> <user>
[--dry-run] [--resume]</code>), and <code>tests/MaildirSourceTest.php</code>
covering parseFolderName / parseFlags / extractMessageId /
messageMetaFromPath / skipFile / isValid / folders /
messageFiles against a deterministic sample tree under
<code>tests/test_files/</code> (frozen unix-timestamp
mtimes set in setUp() since git does not preserve mtime through
a patch). Subfolder hierarchy via Maildir++ dot convention
(".Sent" -> "Sent", ".Archive.2025" -> "Archive/2025"). Flag
mapping per Maildir++ section 4 (R/S/T/D/F ->
\Answered/\Seen/\Deleted/\Draft/\Flagged; P/Passed dropped;
"new/" messages get \Recent added). Dovecot metadata files
skipped. tmp/ not enumerated. --resume dedupes by Message-ID
against the destination folder.</p></li>
<li class="item"><p><span class="num">49.</span> <span class="check">✓</span> <strong>Mail Services fieldset in Server Settings</strong> (commit
<code>76bdec2</code>). 8 new <code>MAIL_*</code> Profile.php constants, fieldset
with dropdown + sub-config visible only when MailSite is
selected. No listener spawned; intent recorded only.</p></li>
<li class="item"><p><span class="num">50.</span> <span class="check">✓</span> <strong>Mail button in SocialControlsElement + userMail
activity stub</strong> (commit <code>e34e230</code>). Button gated on
<code>C\MAIL_MODE !== 'disabled'</code>. Routes through <code>routeUserMail</code>
to <code>SocialComponent::userMail()</code>, registered as a social
activity in both AdminController and GroupController.</p></li>
<li class="item"><p><span class="num">51.</span> <span class="check">✓</span> <strong><code>src/library/ImapClient.php</code>: minimal RFC 3501 client
for use by the Mail activity</strong> (commit <code>510d4b4</code>). Ported
the ~200-line ImapClient class from atto's Anonymous WebMail
demo, brought up to Yioop conventions.</p></li>
<li class="item"><p><span class="num">52.</span> <span class="check">✓</span> <strong>Mail activity: external IMAP accounts UI</strong> (commit
<code>f53ba8b</code>). Add/Edit/Delete account forms, account list,
inbox listing, single-message view; libsodium credential
encryption with master key in private DB; CRUD model with
user-scoping. 13 test cases / 36 assertions, all pass.</p></li>
<li class="item"><p><span class="num">53.</span> <span class="check">✓</span> <strong>Mail activity: MailSite-stored mail.</strong> Decision made
in favor of direct in-process MailStorage calls (no
socket-loopback layer) wrapped by a backend abstraction so
IMAP and MailSite share the same handler surface.
Implementation arc (12 patches over ~3 sessions):</p>
<p><strong>a. Backend abstraction.</strong> New
<code>src/library/MailBackend.php</code> β abstract base with 21
methods: connection lifecycle (close), account identity
(accountId, displayName, senderEmail, isSyntheticAccount),
folder ops (listFolders, annotateUnreadCounts,
createFolder, renameFolder, deleteFolder, purgeFolder),
messages (listMessages, fetchMessage, setFlag, moveMessage,
deleteMessage, appendMessage), and bulk variants
(setFlagBulk, moveMessagesBulk, deleteMessagesBulk). Static
factory <code>MailBackend::forAccountId($account_id, $data,
$component)</code> routes by <code>MAILSITE_ACCOUNT_ID</code> (=0,
synthetic) vs IMAP account row. Errors throw
<code>MailBackendException</code> with localized messages.</p>
<p><strong>b. Concrete backends.</strong>
<code>src/library/ImapMailBackend.php</code> wraps the existing
<code>ImapClient</code> with lazy connect, cached selected-folder
state, and <code>translateException()</code> that maps <code>ImapClient</code>
<code>ERROR_*</code> tokens to localized <code>social_component_mail_err_*</code>
strings. <code>src/library/MailSiteMailBackend.php</code> wraps the
<code>MailSiteFactory::build()</code> direct-API path: in-process,
no sockets. Both share the abstract interface contract.</p>
<p><strong>c. Handler migrations.</strong> Each user-visible Mail handler
in <code>SocialComponent</code> migrated to the backend dispatch
pattern: <code>MailBackend::forAccountId(β¦) + $backend->method(β¦)
+ $backend->close()</code>. Migrated:
<code>userMailListFolders</code>, <code>userMailListMessages</code>,
<code>userMailViewMessage</code>, <code>userMailFolderAction</code>,
<code>userMailToggleFlag</code>, <code>userMailBulkAction</code>,
<code>userMailArchiveToSent</code>, <code>userMailDownloadAttachment</code>,
<code>userMailDownloadAllAttachments</code>,
<code>userMailBuildReplyPrefill</code>, <code>userMailLoadMessages</code>.
Each migration also dropped the corresponding inline-IMAP
worker (<code>userMailFindSentFolder</code>, <code>userMailFindTrashFolder</code>,
<code>userMailListAll</code>, <code>userMailBulkApply</code>, <code>userMailFolderApply</code>,
<code>userMailFetchBody</code>, <code>userMailImapErrorMessage</code>).</p>
<p><strong>d. Send-archive bug fix.</strong> <code>ImapMailBackend::appendMessage</code>
had two latent bugs (wrong arg order on the
<code>ImapClient::appendMessage</code> call, wrong response-shape check)
that didn't manifest until the first migrated handler
actually exercised it; fixed when the migration of
<code>userMailArchiveToSent</code> surfaced them.</p>
<p><strong>e. Connection sharing.</strong> Discovered that
<code>imapListMessagesAdvanced</code> (the IMAP-specific sort/filter
branch of <code>userMailListMessages</code>) opened a <em>second</em>
connection per request, tripping Gmail's per-account
concurrent-session limit. Added public-with-<code>@internal</code>
<code>imapClient()</code> and <code>replaceImapClient($new_client)</code>
accessors on <code>ImapMailBackend</code> so the advanced flow shares
the backend's already-open client. Same pattern then applied
to <code>userMailLoadMessages</code>'s IMAP branch.</p>
<p><strong>f. Attachment download Apache-vs-WebSite fix.</strong>
<code>userMailStreamAttachment</code> and <code>userMailStreamAttachmentsZip</code>
used bare PHP <code>header()</code> and <code>exit()</code>. Under Apache this
works; under WebSite mode the response stream has already
started by the time the handler runs, so <code>header()</code> warns
and <code>exit()</code> kills the long-running server process.
Switched to <code>$parent->web_site->header(β¦)</code> and
<code>\seekquarry\atto\webExit()</code>. Same fix-pattern applied
proactively to all three <code>userMessages</code> JSON
<code>echo + exit()</code> handlers (load-more-messages,
invalid-thread bailout, success-emit).</p>
<p><strong>g. UI affordances added during the migration.</strong>
Per-folder unread counts in the sidebar (visible for
both MailSite and IMAP); Junk folder in MailSite's default
folder set (special-use <code>\Junk</code>); user-created MailSite
folders surface in the sidebar (merge against
<code>defaultFolders()</code> in <code>MailSiteMailBackend::listFolders</code>);
Move action on the message view (dropdown self-submits
<code>onchange</code>, ordered as
<code>[β©] [β©Β«] [β‘] [Move...βΎ] | [π]</code>, RTL-correct via
<code>border-inline-start</code>); sidebar account-list scroll
position persisted via <code>sessionStorage</code> so the active
account stays in view after action redirects.</p>
<p><strong>h. Test coverage.</strong> Three new test files in
<code>tests/</code>: <code>MailHeaderParserTest.php</code> (17 cases covering
<code>parse</code>/<code>decodeMimeWord</code>/<code>splitAddresses</code> β pure-function
parser); <code>MailSiteMailBackendTest.php</code> (13 cases against
a <code>RamMailStorage</code>-backed MailSite installed via the new
<code>MailSiteFactory::setTestOverride()</code> test seam; covers
synthetic account shape, folder catalog and merge,
append/fetch round-trip, flag toggling, move/delete soft-
vs-hard, bulk variants). <code>ImapMailBackendTest.php</code> is
deferred (needs an <code>ImapClient</code> socket-pair pattern like
<code>ImapClientTest</code>; the IMAP backend wraps <code>ImapClient</code>
which is already covered, so the marginal value vs the
work to script wire-level responses is moderate). New
<code>tests/ManyEmailExperiment.php</code> injects N synthetic
messages (default 500) into FileMailStorage for a target
user so the webmail UI can be exercised under realistic
counts (scrolling, pagination, sort, filter, bulk select);
knobs for date span, attachment ratio, seen/flagged ratios.</p>
<p><strong>i. Post-migration cleanup (deferred).</strong> A patch 12
pass would revert the public-<code>@internal</code> visibility leaks
introduced by patches 5, 6, 18 (<code>userMailLog</code>,
<code>userMailProbeFolderUnread</code>, <code>imapClient</code>,
<code>replaceImapClient</code>) back to protected once the advanced
inbox + load-more flows fold into the backend. Estimated
~-100 lines. Not blocking webmail use. <em>Separately, the
<code>MAIL_CLONE_JOB.LAST_ERROR</code> column drop that this arc made
possible (per-message errors moved to <code>MAIL_CLONE_ERROR</code>) is
fully shipped and verified 2026-06-07 β see the Step 5 note
in the phased ordering.</em></p>
<p><strong>j. Remaining migration patch.</strong>
<code>mail-backend-search</code> β search UI + handler migration β
is the last handler-migration patch. Currently
<code>userMailSearch</code> (when wired up) still uses inline IMAP
SEARCH; will move through a new <code>$backend->search($folder,
$query)</code> abstract once the search UI is in place.
Estimated ~150 lines.</p></li>
</ul>
<h2>Mail backend / MailSite β backlog surfaced by phase 2 work (mostly post-v10; item 55 landed during phase 2)</h2>
<ul class="items">
<li class="item"><p><span class="num">54.</span> <span class="check">✓</span> <strong>Cross-account move.</strong> Move a message from one mail
account to another (e.g. IMAP β MailSite, or
Gmail β Pollett-IMAP). Fetch-from-A + append-to-B +
delete-from-A, with rollback on any step's failure. <em>Done:</em> the
move UI exposes "Move to another account" alongside the existing
within-account move dropdown, and an external IMAP account can be cloned
wholesale into local MailSite storage.</p></li>
<li class="item"><p><span class="num">55.</span> <span class="check">✓</span> <strong>MailSite outbound sending.</strong> <em>Landed during phase 2
(commits <code>3d9967c</code>, <code>7c36dfb</code>, <code>f6a2826</code> on May 24-25)
ahead of the post-v10 schedule.</em>
<code>userMailDispatchMailsiteSend</code> in <code>SocialComponent</code> builds
the wire message once via <code>MimeMessage::build</code>, then
classifies recipients by domain: local recipients (whose
domain is in <code>MAIL_DOMAINS</code>) are delivered directly through
<code>MailSite::deliverMail</code> (in-process, no SMTP loopback);
external recipients are grouped by lowercase domain, each
domain's MX records are resolved, and per-MX-host the
outbound <code>SmtpClient</code> tries port 25 first with a port 587
fallback for networks that block 25. The session uses
<code>opportunistic</code> STARTTLS (upgrades when the peer advertises
it, plaintext otherwise) and no AUTH (peer-MTA mode rather
than smarthost submission). After delivery the same bytes
are archived to the sender's Sent folder via the MailSite
backend's <code>appendMessage</code>. Implementation chose option (a)
of the original two-options sketch (local MTA relay) but
used peer-MTA outbound rather than handing off to an
upstream relay; this avoids needing a configured smarthost
on each install but means the deployment server must have
outbound port 25 (or 587) reachable from its network. See
item 60 (SmtpTunnel) for the workaround when a deployment's
network egress blocks both.</p></li>
<li class="item"><p><span class="num">56.</span> <span class="box">☐</span> <strong>MailSite spam classification.</strong> The Junk folder exists
(added during the backend-arc work) but no classifier writes
to it. Needs an SCL hook in <code>MailSite::deliverMail</code>, Mark/
Unmark Junk buttons in the message view, and a classifier
(Bayesian against the user's existing Junk + non-Junk corpus,
or DNSBL lookups for the upstream IP). Sequencing: ship
Mark/Unmark Junk first (manual user training data), then
Bayesian on top of the accumulated training set.</p></li>
<li class="item"><p><span class="num">57.</span> <span class="box">☐</span> <strong>MailSite unread-count session caching.</strong> Per-folder
unread probe currently re-runs every <code>listFolders</code> call (so
the badges are always live). For users with many folders +
large message counts the probe walks each folder's
metadata; a short-TTL session cache (mirroring the IMAP
cache pattern) would amortise the cost.</p></li>
<li class="item"><p><span class="num">58.</span> <span class="box">☐</span> <strong>MailSite sort / filter / unread-only / flagged-only.</strong>
The IMAP <code>imapListMessagesAdvanced</code> flow supports
server-side SORT, SEARCH-based filters, and the unread-
only / flagged-only toggles. MailSite currently runs
<code>listMessages</code> newest-first only, no filters. The "honest
branch" in <code>userMailListMessages</code> exists because of this
capability gap. Implement equivalents in
<code>MailSiteMailBackend::listMessages</code> (the storage already
has the indices; just needs to expose them).</p></li>
<li class="item"><p><span class="num">59.</span> <span class="check">✓</span> <strong>Test Mail Mode + Test Mode Fallback Port.</strong> Two
related Server Settings toggles, both surfaced inside the
Mail Services fieldset. <code>C\MAIL_TEST_MODE</code> (boolean,
default false) controls two behaviors at once: (1) the
compose-form address validation in
<code>SocialComponent::userMailIsValidAddress</code> relaxes from
PHP's strict filter_var FILTER_VALIDATE_EMAIL to a looser
"non-empty-local@non-empty-domain" check, so test@127.0.0.1
and user@localhost are accepted in the To/Cc/Bcc fields; (2)
<code>MailSiteFactory::build</code> skips the <code>onMailFrom</code>/<code>onRcptTo</code>
FQDN-reject hook so MailSite's SMTP listener stops refusing
non-FQDN senders/recipients at MAIL FROM and RCPT TO time.
<code>C\MAIL_TEST_MODE_FALLBACK_PORT</code> (integer, default 0) names
a fallback outbound port the MailSite-to-peer-MTA path tries
after a TCP-level failure on port 25; consulted only when
test mode is on and the port is nonzero. The port-input
field in Server Settings is shown/hidden via the same
<code>setDisplay</code> pattern the MAIL_MODE-dependent blocks use:
<code>mail-test-mode.onchange</code> flips <code>mail-test-mode-port-row</code>.
Replaces an earlier unconditional 25β587 fallback (the
speculative path Postfix didn't actually accept
unauthenticated submission on). Companion: the standalone
<code>smtp_tunnel.php</code> (item 60) β set the fallback port to the
tunnel's listen port to reach an upstream Postfix from a
network that blocks 25. The null reverse-path <code><></code> is
always accepted on MAIL FROM regardless of this setting.
Persisted through <code>ProfileModel::$profile_fields</code> and the
<code>SystemComponent::updateProfileFields</code> boolean-fields list.</p></li>
<li class="item"><p><span class="num">60.</span> <span class="check">✓</span> <strong><code>smtp_tunnel.php</code>: standalone byte-level TCP forwarder
for port-25 egress workarounds.</strong> Single-file PHP CLI
script, no Yioop dependencies and no namespace; lives
outside the Yioop tree so it can be deployed on the
upstream-SMTP host independently of any Yioop release.
Single-process <code>stream_select</code>-driven TCP forwarder.
Accepts plain TCP connections on a configurable listen
port and bridges each one to a single fixed upstream
<code>host:port</code>, copying bytes bidirectionally with no
protocol awareness. Run on the SMTP-bearing host
(typically pollett.org) listening on port 5252 (literally
"2525 backwards"; well clear of the IANA well-known range
so common egress filters do not block it) and forwarding
to <code>127.0.0.1:25</code>. A Yioop install running on a network
that blocks outbound port 25 then points its outbound
<code>SmtpClient</code> at <code>pollett.org:5252</code> and reaches the
upstream Postfix transparently. CLI: <code>php smtp_tunnel.php
[listen_port] [forward_host] [forward_port] [allow_csv]</code>
where the optional fourth argument is a comma-separated
list of source IPs permitted to connect (empty means no
restriction). Operationally the tunnel is the
deployment-side complement to MailSite's outbound MTA
(item 55): MailSite resolves MX and tries 25β587 from
the client side; <code>smtp_tunnel.php</code> makes the listening
side reachable when the client side is firewalled.
<em>Caveat: because the upstream Postfix sees the tunnel
host as the peer, allowing this through requires either
binding the upstream listener to loopback (the safe
shape) or restricting the tunnel's allow-list to the
operator's known cruise-ship egress IP -- otherwise the
tunnel becomes a small open relay onto port 25.</em></p></li>
<li class="item"><p><span class="num">61.</span> <span class="check">✓</span> <strong>PHP 8.5 dynamic-property declaration on SmtpClient.</strong>
Declared <code>public $last_response_body</code> on <code>SmtpClient</code>;
it was being assigned in the constructor and in
<code>readResponseGetCode</code> without a declared property,
surfacing as "Creation of dynamic property
seekquarry\yioop\library\SmtpClient::$last_response_body
is deprecated" on every mail send under PHP 8.5. A
follow-up sweep should grep all classes for assigns to
undeclared <code>$this->*</code> so we catch the rest before they
hit users on 8.5 (the 8.2 deprecation is now noisy enough
that one bare <code>mail-send</code> test surfaces it).</p></li>
</ul>
<h2>Atto WebSite β non-blocking connection lifecycle</h2>
<ul class="items">
<li class="item"><p><span class="num">62.</span> <span class="check">✓</span> <strong>Make the <code>WebSite</code> TCP connection lifecycle fully
non-blocking.</strong> Origin: observed bursty hangs on pollett.org,
where <code>WebSite</code> serves TLS directly on 443 (no Apache in
front). Root cause: the single-threaded accept/serve loop
blocked whenever one client stalled during a synchronous step
(TLS handshake, protocol detection, or a frame read), freezing
every other connection. Surfaced and fixed in the 2026-06-07
session. Verified in production with two standalone client
probes (<code>diag_accept_blocking.php</code>, <code>diag_h2_frame_stall.php</code>)
that stall N connections at a chosen lifecycle stage and time
a legitimate concurrent request. Sub-stages, each its own
verified patch on <code>master</code>:
a. <strong>Stage 1 β non-blocking accept + deferred TLS handshake.</strong>
Ported MailSite's deferred-handshake pattern: <code>accept</code> sets
the socket non-blocking immediately and returns secure
connections in a <code>handshake</code> protocol state with a
deadline; <code>stepHandshake</code> peeks for the <code>0x16</code> TLS record,
steps <code>stream_socket_enable_crypto</code> across select wakes
(done / failed / needs-more-IO), and promotes on
completion. <code>HANDSHAKE_TIMEOUT</code> on <code>ConnectionAcceptor</code>;
<code>cullDeadStreams</code> reaps pending handshakes by deadline (they
have no modified-time, so the generic idle timeout would
wrongly reap them instantly). Production: phase 3 went from
FAILED + an <code>SSL: Broken pipe</code> storm to ~0.9x baseline.
b. <strong>Stage 2 β deferred post-TLS protocol detection.</strong> The
post-handshake HTTP/2-magic read was itself blocking (a
client that finished TLS but sent no bytes blocked).
<code>classifySecureFirstBytes</code> accumulates the first decrypted
bytes in a per-connection detect buffer and decides h1/h2
non-blocking. Production ~1.0x.
c. <strong>Stage 3a β bound the select wait by handshake deadline.</strong>
With no timer alarms the main <code>stream_select</code> used a null
(infinite) timeout, so a silent stalled handshake was only
reaped when unrelated traffic woke the loop. Added
<code>nextHandshakeDeadline()</code> and clamp the wait to the soonest
pending deadline. Production ~0.9x.
d. <strong>Stage 3b β non-blocking WebSocket frame reads.</strong>
<code>parseWsRequest</code> had set the socket blocking and looped on
blocking reads, so a slow WS client dribbling a partial
frame blocked the loop. Replaced with a per-connection
input buffer plus a pure <code>WebSocketFrame::parseBuffer</code>
(returns frame + bytes consumed, or null for need-more);
per-frame handling moved to <code>dispatchWsFrame</code>. Public
WebSocket route API (<code>onMessage</code> / <code>onClose</code> / <code>onError</code> /
<code>send</code> / <code>sendBinary</code> / <code>close</code>, and <code>WebSocketFrame::build</code>)
unchanged, so the atto WebSocket example still works.
Verified with a real socketpair: a frame split across 3
reads buffers without blocking and dispatches only when
complete; two frames in one read both dispatch.
e. <strong>Stage 3c β non-blocking HTTP/2 frame reads.</strong> Evidence
first: <code>diag_h2_frame_stall.php</code> established real
H2-over-TLS connections (full preface + SETTINGS) then
stalled mid-frame; pre-fix this measured phase 3 at 146x
baseline (838 ms) with only 2 stall connections openable
before the loop choked. Fixed by a low-risk split: the
~190-line frame-processing body became <code>processH2Frame</code>
(byte-for-byte unchanged), and a new <code>parseH2Request</code> reads
available bytes non-blocking into a per-connection input
buffer, extracts each complete frame (9-byte header +
declared payload), and calls <code>processH2Frame</code> per frame,
draining multiple buffered frames in one pass. Verified a
real HPACK-encoded HEADERS request dispatches both whole
and when fragmented across reads. Production: phase 3
dropped from 146x to ~0.4x baseline, with all 20 stall
connections opening cleanly.
f. <strong>Stage 4 β H3Listener review (no code).</strong> Code-reviewed
the QUIC/HTTP-3 path: it is non-blocking by design β a
single UDP socket multiplexes all connections by connection
ID, <code>accept</code> drains datagrams with non-blocking
<code>stream_socket_recvfrom</code>, per-datagram processing is
bounded, and <code>reapStaleConnections</code> does RFC 9000
idle-timeout reaping, so one stalled H3 client cannot block
others. The only blocking read in the file
(<code>Tls13Engine::fillExact</code> / <code>readRecord</code>) is explicitly off
the QUIC path (a standalone TLS-over-TCP helper per its own
comment). No change needed.</p>
<pre><code>*Open follow-up (optional, not blocking): `initH2Request`'s
read of the client's initial SETTINGS frame at H2
connection-establishment is still blocking. It is a distinct,
narrow window (only during H2 setup) and is not what the
diagnostic measured; converting it would mean deferring
establishment across select wakes like the handshake detect
phase. The two diagnostics are worth keeping as regression
checks. Note observed during testing: phase-3 latency spikes
can also come from HDD-under-load contention on response
serving (filesystem latency, independent of loop blocking) β
re-run a few times and compare typical figures, not a single
worst case.*
</code></pre>
<hr /></li>
</ul>
<h2>Production reliability & streaming (atto WebSite / MailSite)</h2>
<p>Production-driven work outside the originally planned phases, recorded
as numbered items here and grouped under "Phase 2.5 β Ongoing
production reliability" in the phased ordering below.</p>
<ul class="items">
<li class="item"><p><span class="num">64.</span> <span class="check">✓</span> <strong>ACME certificate auto-renewal (2026-06-08).</strong> <code>AcmeRenewJob</code>
MediaJob, ARI-gated via <code>AcmeManager::renewalWindow</code> with a
<code>ONE_MONTH</code> expiry fallback, plus in-place
<code>WebSite::reloadSecureCertificate</code> driven by a once-a-minute
cert-watch timer in <code>index.php</code>, so the secure listeners pick up a
renewed certificate without a restart.</p></li>
<li class="item"><p><span class="num">65.</span> <span class="check">✓</span> <strong>Per-domain Appearance (2026-06-09/10).</strong> Each custom-route
domain can override the full Appearance set (colors, logos,
favicon, background image, theme, landing page, searchbar), with a
global fallback when unset. New <code>DOMAIN_APPEARANCE</code> table
(migration 104), overlaid at render time, edited through a domain
picker in the Appearance activity; per-domain images live in
<code>resources/<domain>/</code> served via a new pretty-URL route and applied
across all 13 logo/favicon surfaces through a new <code>LogoHelper</code>.</p></li>
<li class="item"><p><span class="num">66.</span> <span class="check">✓</span> <strong>Restart Server made reliable (2026-06-13).</strong> Restart was
flaky β daemons sometimes killed-not-relaunched, the web server
sometimes killed-not-respawned. Root cause: the replacement secure
listener's bind-retry window (10 Γ 250 ms) was shorter than the
exiting TLS server's privileged-port teardown, so the replacement
exhausted its retries and exited, leaving no server;
<code>BIND_ATTEMPTS</code> widened 10β40 in <code>WebSite</code>. Supporting fixes:</p>
<ul>
<li>
<p><code>interruptibleSleep($duration, $step, $wake_callback)</code> in
<code>Utility.php</code>, with idle daemon waits in MediaUpdater / Mirror /
QueueServer / Fetcher converted to wake promptly on a stop
request instead of sleeping out a fixed interval.</p>
</li>
<li>
<p><code>CrawlDaemon</code> lock files now store <code>"timestamp pid"</code>;
<code>isProcessAlive</code> (POSIX <code>posix_kill($pid, 0)</code>) lets <code>start()</code>
clear a stale lock and proceed while never killing a live
daemon β removes the manual <code>rm</code>-the-lock footgun. <code>statuses()</code>
skips stale locks.</p>
</li>
<li>
<p>A non-blocking boot-drain state machine in <code>index.php</code>: a
1-second repeating timer advances one daemon per tick through
drain/relaunch, replacing a blocking <code>sleep</code> loop that had frozen
the freshly-restarted event loop for seconds per daemon.</p>
</li>
<li>
<p>A poll-based restart-status page (h2/h3-safe, replacing a
streaming page) with phase wording, including the <code>&</code>-in-URL
and CSRF-freshness fixes that had made the poll receive HTML
instead of JSON.</p>
</li>
<li>
<p>The configure-extensions check gained OpenSSL + Sodium
(required) and POSIX (optional β enables graceful daemon
shutdown and stale-lock clearing).</p>
</li>
<li>
<p>Side fix: a Media-Source Add/Edit form showed a phantom
unlabelled field because the show/hide JS referenced a
nonexistent <code>path-terms</code> id instead of the real <code>path-regex</code>.
Open follow-up: re-verify restart consistency across several
back-to-back restarts (a clean post-restart <code>ps</code> was observed β
web server present with the normal
<code>secure-80-443 β¦ PreviousSession.txt</code> launch arg, daemons idle, no
spin); if the earlier intermittent lingering-old-process or
missing-replacement outcomes recur, the next suspect is the
boot-drain / relaunch state machine rather than the bind window.</p>
</li>
</ul></li>
<li class="item"><p><span class="num">67.</span> <span class="check">✓</span> <strong>HTTP/2 streaming delivery β window-sized spans
(2026-06-13).</strong> Large media and images stalled in Firefox under H2:
the transfer died at ~131 KB (<code>NS_ERROR_NET_PARTIAL_TRANSFER</code>),
then re-requested in a cascade. Root cause: the server committed a
whole large body to one stream and depended on the client extending
that stream's flow-control window; Firefox advertises the default
65535 per-stream window and withholds per-stream <code>WINDOW_UPDATE</code>
for a body it is not actively draining (the connection window
stayed at megabytes β only the per-stream window was pinned at 0).
H1 has no per-stream flow control (works); Safari advertises a 2 MB
window (works). Fix: a <code>WebSite::peerStreamWindow()</code> accessor
exposes the connection's per-stream initial window, and
<code>ResourceController</code> serves each span β byte-range and large
whole-file β at <code>min(MAX_RANGE_STREAM_LEN, that window)</code>, so every
stream completes within the credit the client granted and closes,
the client fetching the next span the way it does against a
conventional server. On a 2 MB-window connection a response goes
out in one or two large spans (no slowdown); on a 65535-window
connection it serves ~64 KB spans. Interim hardening kept as
correct on its own: park-a-flow-blocked-stream-instead-of-resetting
(no stall-RST during paced playback) and holding an early
per-stream <code>WINDOW_UPDATE</code> that arrives before the response
registers the stream. Behaviour note: a large whole-file GET (no
Range header) now returns a capped <code>206 Partial Content</code> +
<code>Accept-Ranges</code> so the client range-fetches the rest β browsers and
media elements handle this; spot-check any non-browser client that
specifically expects a <code>200</code> for a direct large-file download.
<em>Follow-up resolved 2026-06-13 (commit <code>02f0561</code>, "large download
full 200 on h1_1"): over HTTP/1.1 a large whole-file GET (no Range
header) now serves a full <code>200 OK</code> streamed in chunks rather than
the capped <code>206</code>, so non-browser clients that expect a <code>200</code> work;
the window-sized-span <code>206</code> path stays for H2/H3 where the
per-stream flow window requires it. Closes the spot-check above.</em></p></li>
<li class="item"><p><span class="num">68.</span> <span class="check">✓</span> <strong>Event-loop CPU spins (2026-06-13).</strong> <code>MailSite</code> ran a forked
child at ~100% CPU: the select loop added every in-progress
TLS-handshake socket to the <code>stream_select</code> write set, and a
connected socket is almost always writable, so select returned
immediately every iteration while any handshake was pending β fixed
by leaving handshake sockets in the read set only (a "would block"
crypto step is waiting to read the peer's next record; a stalled
handshake is bounded by its deadline in <code>cullDeadStreams</code>). atto's
<code>GopherSite</code> spun at ~100% from a reversed timer subtraction
(<code>microtime(true) - $next_alarm[0]</code> β negative β clamped to a 0
select timeout) plus an always-zero fractional-seconds term; both
fixed (atto tag <code>v2.2.1</code>). The same fractional-seconds bug in
<code>WebSite</code> (correct operand order, so no continuous spin, but
sub-second timer waits collapsed to a 0 timeout) was fixed in
passing; <code>MailSite</code>'s own timer math was already correct.
<code>Fetcher</code> / <code>QueueServer</code> / <code>MediaUpdater</code> / <code>Mirror</code> were checked
and are unaffected β sleep-based loops with no <code>stream_select</code>
timeout arithmetic. Reviewed and intentionally not changed:
<code>H3Listener</code> does not need the streaming or handshake fixes β it
already paces bodies incrementally with large (16 MB) default
per-stream windows, and QUIC does TLS in userspace over UDP so the
<code>stream_select</code> handshake-spin has no analogue. Backport status:
<code>MailSite</code> and <code>WebSite</code> work is backported to atto
(<code>cpollett/atto</code>); the streaming span-sizing logic is Yioop-side
(<code>ResourceController</code>) and does not backport.</p></li>
<li class="item"><p><span class="num">69.</span> <span class="check">✓</span> <strong>First-run & single-secure-port startup robustness
(2026-06-13).</strong> A freshly-cloned, not-yet-configured instance can
now be started and brought up cleanly. Pieces: the crawl daemon
survives an unconfigured WORK_DIRECTORY instead of dying on launch
(<code>CrawlDaemon</code> lets the process live and warns rather than fatal,
commit <code>e67c3c2</code>); a single-secure-port mode (just 443, self-signed
cert auto-written) in <code>index.php</code> (<code>e1fc4fd</code>); maximum request
length derived from PHP's <code>post_max_size</code> so large uploads are not
silently truncated (<code>c180065</code>); the first-run Configureβrunning
handoff polls a real restart-status URL built through
<code>controllerUrl</code> with first-run constants in <code>Config.php</code>, replacing
a streaming page (<code>70a7f97</code>, <code>c3eb12f</code>, <code>0d25e11</code>); and a guard that
warns when a second index server is started with a port already in
use (<code>ada3e8f</code>). This is the headless/browser bring-up path
referenced in the work-directory setup notes.</p></li>
<li class="item"><p><span class="num">70.</span> <span class="check">✓</span> <strong>Command-line <code>restart</code> / <code>status</code> for the running server
(2026-06-13/14).</strong> <code>index.php</code> gained CLI <code>restart</code> and <code>status</code>
subcommands so the live server can be restarted and inspected from
a shell without the web UI: restart routes through the same
restart-server path the UI uses (<code>66738f1</code>), monitors restart
progress and reports phase status (<code>283d957</code>), names the services
it relaunched (<code>241e740</code>), a <code>status</code> command summarises the web
server + daemons (<code>236735f</code>), and bare/<code>--help</code> invocation prints
usage (<code>0f63160</code>). Supporting fix: <code>CrawlDaemon::isProcessAlive</code>
treats an <code>EPERM</code> from <code>posix_kill($pid, 0)</code> as alive-but-other-
user rather than dead (<code>795957b</code>), so process-liveness is correct
when the web user differs from the file owner β the same
cross-user (<code>_www</code>/<code>www-data</code> vs the owning account) hazard the
resource-UI Apache issue surfaced. Builds on item 66.</p></li>
<li class="item"><p><span class="num">71.</span> <span class="check">✓</span> <strong>Headless <code>ConfigureTool</code> command-line mode (2026-06-13).</strong>
<code>src/configs/ConfigureTool.php</code> gained a non-interactive
one-command-per-invocation mode alongside the existing interactive
<code>menu</code>, so a fresh install is configurable with no browser:
<code>work-dir [path]</code>, <code>root-password <old> <new></code>, <code>locale <tag></code>,
and the toggle commands <code>debug</code> / <code>search-access</code> /
<code>page-elements</code> (with <code>+name</code>/<code>-name</code> to turn settings on/off),
plus <code>name-server</code> and <code>robot</code>, each with an argument spec, count
checking, and a usage listing (<code>71dc944</code>, <code>c9606ae</code>, <code>df25cdf</code>,
<code>12c71cd</code>, <code>aaf8f3b</code>; <code>readInput</code>/<code>readPassword</code> consolidated in
<code>Utility.php</code>). This is the seam used to stand up a working
WORK_DIRECTORY + <code>Profile.php</code> + the two SQLite DBs headlessly
(e.g. so <code>CodeTool</code> unit tests can run). Document in the install
guide under item 34.</p></li>
<li class="item"><p><span class="num">72.</span> <span class="check">✓</span> <strong>Page source/history/list visibility + per-domain robots.txt /
humans.txt (2026-06-16/17).</strong> Two related controls built on the
per-domain Appearance foundation (item 65) and the group OPTIONS
bitfield. (a) <em>Visibility:</em> <code>SOCIAL_GROUPS.OPTIONS</code> was refactored
from a packed <code>(render_engine<<1)+encryption</code> into a pure flag
bitfield, with <code>render_engine</code> split out to its own <code>RENDER_ENGINE</code>
column and new flags <code>GROUP_OPTION_PAGE_SOURCE_ALLOWED</code> /
<code>GROUP_OPTION_PAGE_LIST_ALLOWED</code> (migrations v106 / v107). A group
owner can now keep the public β and crawlers β out of wiki page
source and history (one master gating both: the page-level
<code>public_source</code> head var, the history endpoint, the
source/discuss/history nav, and the discussion-thread feed all honor
it) and out of the public page list. Owners stay exempt. (b) <em>robots
/ humans:</em> each web-server secure domain publishes its own files.
<code>RobotsTxtGenerator</code> (a pure library class) builds robots.txt from
per-domain choices stored in the <code>DOMAIN_APPEARANCE</code> blob (disallow
-all, a crawl delay with a None option, per-category crawlable
toggles for search / feeds / wiki with wiki sub-toggles for page
source / feed / list, plus free-form additional directives);
humans.txt is free-form text. <code>robots.txt</code> / <code>humans.txt</code> routes in
<code>src/index.php</code> serve the requesting host's stored text (falling back
to the built-in default), mirroring the <code>.well-known</code> / mta-sts route
pattern; the old static <code>src/robots.txt</code> was removed. Edited through a
new <code>RobotssettingsElement</code> page reached from each domain's
Server-Settings routing panel, with a live Preview. Commits
<code>af06edee</code>, <code>d4a22b44</code>, <code>7abf188a</code>, <code>73b76b77</code>, <code>b2c8b7e8</code>,
<code>f70ded84</code>, <code>87ec0bd0</code>, <code>c842350</code>.</p>
<hr /></li>
</ul>
<h2>Suggested phased ordering</h2>
<p><strong>Phase 0 β Code hygiene + immediate hot-fixes</strong> (complete)</p>
<ul>
<li><span class="check">✓</span> 40 (extend <code>CodeTool needsdocs</code>)</li>
<li>
<p><span class="check">✓</span> 41 (sweep and add the missing docblocks the tool surfaces;
0 findings remaining across <code>src/</code>. Tests tree also covered:
commit <code>048ce3a</code> swept docblocks across <code>tests/</code> to bring the
unit-test suite up to the same standard the production tree
now satisfies.)</p>
</li>
<li>
<p><span class="check">✓</span> 31 partial: <code>crawlAuthHash</code> sibling added and CSRF token
migrated to it; <code>checkCSRFToken</code> now uses <code>hash_equals</code> for the
comparison and validates the new 54-char token layout; unit
test for the new primitive in <code>UtilityTest::crawlAuthHashTestCase</code>.
Indexing callsites of <code>crawlHash</code> untouched as planned.</p>
<p><span class="check">✓</span> 31 partial: the security-relevant
<code>crawlHash(AUTH_KEY . x)</code> callsites (secret-ballot FORM_HASH,
csv_form_hash, page_hash, and the git app-code token) migrated to
<code>crawlAuthHash</code>; the CSV form fingerprint strengthened from the
8-byte <code>crawlHash</code> fold to <code>hash("sha256", ...)</code>;
AUTH_KEY now generated from <code>random_bytes</code>; the ballot verify
and CSRF check compare with <code>hash_equals</code>. Two lingering
<code>mt_rand</code> git-archive temp names converted to
<code>random_int</code>. Arc-plan 4.1.a (crypto) and 4.1.b (constant-time)
done; indexing callsites left on <code>crawlHash</code>.</p>
<p><span class="check">✓</span> 31 partial: arc-plan 4.1.c (drop dead
crypto fallbacks) — <code>crawlCrypt</code> was already on
<code>random_bytes</code>, and the last two stray <code>mt_rand</code>
git-archive temp names went to <code>random_int</code>. 4.1.d (cookie and
header hardening) — session cookie set <code>HttpOnly</code>,
<code>SameSite=Lax</code>, and <code>Secure</code> over HTTPS; CSP
<code>frame-ancestors</code> and <code>nosniff</code> were already
central. The cookie and header hardening lands in Phase 4.1, not Phase
5.</p>
<p><span class="check">✓</span> 31 partial: arc-plan 4.1.d (response
and cookie hardening) done — session cookie HttpOnly, SameSite, and
Secure over HTTPS. 4.1.e (input and output paths) spot-checked: inputs go
through Controller::clean, no view echoes a raw request value, upload base
names use pathinfo basename, and resources are served read-only
(readfile/fread, never include/eval) so uploaded scripts do not run; the
one gap, a missing "<code>..</code>" strip in the resource-folder write
path, was closed.</p>
</li>
<li>
<p><span class="check">✓</span> 33(a): new installs default to <code>EMAIL_RECOVERY</code>. Surgical
fix in <code>ProfileModel::updateProfile</code> β adds <code>RECOVERY_MODE</code>
to <code>$not_null_fields</code>, plus a <code>shouldKeepZero</code> policy helper
so an admin's deliberate <code>NO_RECOVERY = 0</code> setting survives
saves. New <code>tests/ProfileModelTest.php</code> with 8 assertions.</p>
</li>
<li>
<p><span class="check">✓</span> 14 (rename <code>MailServer</code> β <code>BulkEmailClient</code> and its file;
callers, test, autoload-by-namespace all updated)</p>
</li>
<li>
<p><span class="check">✓</span> 12 partial: Message-ID, MIME-Version, Content-Type,
Content-Transfer-Encoding added to both branches of
<code>MailServer::sendImmediate</code> (PHP <code>mail()</code> and the SMTP path).
Helper <code>MailServer::messageIdDomain</code> factored out and unit-
tested. Full DKIM / List-Unsubscribe / Reply-To / Return-Path
still deferred to phase 2.</p>
</li>
<li>
<p><span class="check">✓</span> 24 partial: <code>fennec</code> UA token dropped from the mobile
sniff in <code>src/index.php</code> (commit <code>c590f5d</code>; Fennec was
discontinued in 2020 when Mozilla replaced it with Fenix).
Full Sec-CH-UA-Mobile migration with <code>Accept-CH</code> header is
the remaining piece, deferred to phase 5.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: bumped <code>session.gc_maxlifetime</code> to match
<code>AUTOLOGOUT</code></strong> in <code>src/index.php</code> (commit <code>9557889</code>,
<code>early-logout-fix</code> tarball). Default PHP <code>gc_maxlifetime</code> is
1440 seconds (24 minutes), which was triggering early logouts
for users idle longer than that even though Yioop's intended
session timeout per <code>AUTOLOGOUT</code> is much longer. The fix sets
<code>session.gc_maxlifetime</code> and <code>session.cookie_lifetime</code> to
<code>AUTOLOGOUT</code> before <code>session_start()</code> so PHP's GC matches
Yioop's policy. Surfaced while testing the EMAIL_RECOVERY
default; not in the original phase 0 list. <strong>Note: this fixed
the obvious case but random logouts still occur on at least
one deployment, indicating an environment-level cause this
per-request <code>ini_set</code> cannot reach. See deferred item below
in Phase 3 for the deeper fix.</strong></p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: PHP 8.5 forward-compat cleanups</strong> (commit
<code>6e46da2</code>). Dropped deprecated calls to <code>imagedestroy()</code> (GC is
automatic since PHP 8.0; the function emits an E_DEPRECATED in
8.5) and to <code>xml_parser_free()</code> (same story). Replaced one
<code>list($a, $b) = ...</code> destructure that no longer parsed cleanly
under 8.5's stricter list grammar with the bracketed
<code>[$a, $b] = ...</code> form. Surfaced when running yioop.com on
8.5 to validate the PHP 8.1 floor wasn't accidentally too low;
the same fixes are forward-compat work for whenever the floor
rises further.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: dropped empty <code>HiTokenizer::partsOfSpeechTestCase</code>
scaffolding</strong> (commit <code>cc3776f</code>). The Hindi tokenizer's test
class had a method named <code>partsOfSpeechTestCase</code> with no
assertions β leftover scaffolding from a never-implemented
POS-tagging feature. Removed rather than left as a 0-assertion
passing case that misleads about coverage.</p>
</li>
</ul>
<p><strong>Phase 1 β Codebase floor</strong> (complete)</p>
<ul>
<li>
<p><span class="check">✓</span> 38 (PHP minimum pinned at 8.1; composer.json / INSTALL.txt /
SystemComponent runtime check / 25 locale strings reconciled;
6 pre-existing locale translation bugs fixed in passing β
commit <code>a1cd8f5</code>)</p>
</li>
<li>
<p><span class="check">✓</span> 31 partial (<code>crawlCrypt</code> fallbacks for <code>mcrypt_create_iv</code>
and <code>mt_rand</code> dropped now that PHP 8.1 guarantees <code>random_bytes</code>
β commit <code>1630deb</code>. All 10 remaining <code>mt_rand</code> sites tree-wide
converted to <code>random_int</code> for defense in depth β commit
<code>924ad9e</code>. Two safety guards added: <code>AdvertisementModel</code>
now early-returns <code>[]</code> when <code>TOTAL_AMOUNT < 1</code> instead of
silently calling <code>mt_rand(1, 0)</code>; atto <code>WebSite</code> LRU eviction
now skips when <code>$num_unmarked == 0</code> instead of silently
passing <code>(0, -1)</code>. Both were pre-existing edge cases that
<code>mt_rand</code> masked but <code>random_int</code> would <code>throw</code> on. The
broader item 31 (full security audit) is still pending for
phase 4.)</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: locale backslash-corruption fix.</strong> Surfaced while
doing the PHP-floor locale-string sweep: <code>LocaleModel::update
Translation</code> (line 783) was unconditionally applying <code>addslashes</code>
on every save, so any string with an apostrophe doubled its
backslash count on every round-trip through Manage Locales.
Some strings had accumulated hundreds of backslashes. Four
commits: <code>09165ed</code> replaces the writer's <code>addslashes</code> with a
proper <code>iniEscape</code> helper (root-cause fix); <code>83819ca</code> re-emits
every <code>configure.ini</code> through the new path (497 lines across 11
files; pure encoding normalization, semantically transparent);
<code>f05b751</code> repairs 31 cross-contaminated keys across 7 locales
(de had Spanish strings, he had Japanese strings, pt was on
PHP 7.1 from a stale machine-translation pass, etc.) and resets
<code>he</code> to the en_US fallback for a native-speaker pass later
(item 42); <code>8d7b816</code> strips the runaway escape chains that the
normalization preserved (467 lines; <code>\\{2,}'</code> pattern collapses
to <code>'</code>, audit-verified safe against legit regex content like
<code>vi_VN</code>'s <code>\b</code> word-boundary). Cleared 518 corruption sites
total. Pre-requisite for the broader item-42 translation audit.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: <code>strpos</code>/<code>substr</code> β <code>str_contains</code>/<code>str_starts_with</code>/
<code>str_ends_with</code></strong> (145 sites across 49 files; commit <code>139f9e7</code>).
Word-boundary <code>\bstrpos\b</code> anchor prevents accidental <code>mb_strpos</code>
matches; paren-aware non-greedy capture avoids the cross-line
matching trap; escape-aware length check for substr literals
(<code>"\r\n"</code> with N=2 converts correctly). Skips <code>stripos</code> (semantic
change without <code>mb_strtolower</code>) and <code>($pos = strpos(...)) !== false</code>
assignment-in-condition forms (need the position).</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: <code>switch</code> β <code>match</code> for simple-expression arms</strong>
(10 sites across 11 files; commit <code>c4f07c1</code>). Audited all 96
switches; only 10 had value-returning arms suitable for <code>match</code>.
PhraseModel letter table, nl/it/en_US Tokenizer vowel/cons,
NWordGrams 4-arm via list-destructure, GroupModel EXIF
orientation (preserves no-op default), Utility CSS unit
conversion, PdfProcessor 2Γ char maps, LocaleModel direction,
PartialZipArchive decompression. Two follow-ups (<code>f28cde9</code>,
<code>a5fb3f3</code>, <code>7b40335</code>) collapsed column-aligned <code>=></code> per the
fragile-layout rule in code I wrote, plus a sweep of the atto
files (back-ported to upstream atto).</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: nullsafe <code>?-></code> for explicit null guards.</strong> 11 sites
total across 4 files in 2 commits (<code>0bf2054</code>, <code>9141f29</code>).
<code>if ($x) { $x->m(); }</code> β <code>$x?->m();</code> for fire-and-forget calls;
<code>$x !== null ? $x->y : null</code> β <code>$x?->y</code>; <code>$x !== null && $x->y</code>
β <code>$x?->y</code> (preserving falsy short-circuit semantics). All
changes contained to atto WebSite / FeedArchiveBundle /
FeedDocumentBundle / MailSite. Wider scan found no further
clean candidates.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: constructor promotion across 45 classes.</strong> 4 commits
(<code>f6dc46a</code>, <code>628dc49</code>, <code>4efe97b</code>, <code>e7963c3</code>). Batch 1 (8 classes):
pure-assignment constructors where param names matched property
names β StaticTurnAuthenticator, Element, Transport, Flag,
Connection, ConnectionAcceptor, Listener, QuicStream. Batch 2 (14):
mixed-assignment constructors (some params promoted, body keeps
non-trivial setup) β Frame subclasses (HeaderFrame, DataFrame,
RstStreamFrame, PushPromiseFrame, PingFrame, GoAwayFrame,
WindowUpdateFrame, ContinuationFrame), WebSocket, FileAuthenticator,
StringArray, HashTable, PriorityQueue (also removed redundant
<code>$num_values</code> declaration that shadowed parent), SparseMatrix,
MediaJob, LRUCache, Trie, SuffixTree, PageIterator, View. Batch 3
(15): Frame, SettingsFrame, H3Connection, BulkEmailClient,
DocIterator, WordIterator, GroupIterator, MixArchiveBundleIterator,
IndexShard, LSMTree, Tier, IndexDictionary, MessagesBundle,
FileCache, VersionManager, Model. Batch 4 (8): WebSite (body
restructured so <code>$this->base_path</code> is updated in place by the
conditional default logic), WebArchiveBundle, IndexArchiveBundle,
CrawlQueueBundle, DoubleIndexBundle (also fixed the PHP-8.2
dynamic-property deprecation for <code>$dir_name</code>), BloomFilterBundle,
NetworkIterator, Bzip2BlockIterator. Skipped: name-mismatch
cases (Component, Layout, WebSite Listener at line 6725) that
would force visible API renames; constructors where the param
is transformed before assignment (ContextTagger's <code>$lang</code>,
StochasticTermSegmenter's <code>$lang</code>); cases where the param
shadows a parent property (TextArchiveBundleIterator,
DatabaseBundleIterator). Net effect: ~600 fewer lines across
src/library, constructors now self-documenting.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: removed unused <code>Padding</code> trait from atto WebSite.</strong>
Commit <code>36b6597</code> β 57 lines deleted. The trait had no <code>use Padding;</code>
consumers anywhere; PADDED-flag handling lives inline in
HeaderFrame/DataFrame <code>parseBody</code> methods. Pre-existing dead
code from the v9.5.3 baseline AI-generated atto scaffolding.
Back-ported to upstream atto.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>Ad-hoc: int-backed enums for QuicStream state machines.</strong>
Commit <code>4728703</code>. Two file-scope enums in <code>H3Listener.php</code>:
<code>RecvState</code> (Open=0, SizeKnown=1, Done=2, Reset=3) and
<code>SendState</code> (Ready=0, Data=1, DataSent=2, Done=3, Reset=4).
Replaces 9 untyped int constants on QuicStream; typed properties
<code>public RecvState $recv_state</code> and <code>public SendState $send_state</code>;
12 internal references updated. Previously <code>RECV_OPEN = 0</code> and
<code>SEND_READY = 0</code> were interchangeable ints; now distinct types
catch cross-channel mix-ups at runtime. Surveyed wider codebase
for enum candidates β CrawlConstants is too tightly coupled
(inherited via <code>implements</code> by 56 classes) and other constant
clusters are config values, not closed sets. QuicStream is the
one clean win.</p>
</li>
<li>
<p><span class="check">✓</span> <strong>39 β Coding.pdf / Coding-guidelines wiki rewrite for the
PHP 8.1 floor.</strong> Surgical scope: dropped the PHP-5.4-compat rule
(PHP item 11) and replaced with an explicit PHP 8.1 floor plus
prefer-modern-form guidance covering match, str_contains/starts/
ends, nullsafe ?->, constructor promotion, enums, and random_int.
Updated PSR-12 reference to also cite PER Coding Style 3.0 (the
PHP-8.x extension of PSR-12). Bumped copyright year in the
example license block from 2023 to 2026 (two sites). Replaced
the .html-ltr / .html-rtl mandate with logical-property
guidance (preview of item 27) while keeping the legacy
selectors documented as still-supported. Added a parallel
"prefer media / container queries" note alongside the .mobile
selector (preview of items 23-25). Added a new <strong>Security</strong>
section between CSS and HTML codifying: no mt_rand for
security-relevant values; explicit range guards before
random_int when bounds derive from user-influenced data;
hash_equals for secret comparisons; HMAC over server-side
secret for auth tokens (not raw hash); password_hash /
password_verify only via crawlCrypt; no @ error suppression;
HTML/URL/SQL escaping rules; clean() the request supergloblals;
CSRF token verification for state-changing endpoints; do not
log credentials. Deliverable: full revised wiki source as
<code>Coding-wiki-revised.txt</code> β pasted whole-file into the wiki
editor; the wiki's built-in diff preserves the old version.
Re-render Coding.pdf from the revised wiki page as the final
step.</p>
</li>
</ul>
<p><strong>Phase 2 β Mail infrastructure</strong> (complete; the optional
<code>mail-backend-search</code> handler migration and the item-13 outbound
trim remain, plus item 12 below; item 11 is done and the
deliverability docs moved to Phase 11; the patch-12 visibility
cleanup and the <code>MAIL_CLONE_JOB.LAST_ERROR</code> column drop are both
done β verified in code 2026-06-07)
<em>(Update 2026-06-14: the item-13 outbound trim is now done; only the
optional <code>mail-backend-search</code> handler migration and item 12's
RFC-5322/DKIM/List-Unsubscribe header work remain in this phase.)</em>
<em>(Update 2026-06-19: item 12 is now done too (verified in code) β
the email subscribe/unsubscribe arc landed the List-Unsubscribe
headers on top of the existing <code>MimeMessage::build</code> (RFC 5322) and
<code>SmtpClient::dkimSign</code> (DKIM); see item 12 above. Only the optional
<code>mail-backend-search</code> handler migration remains open in this phase.)</em>
<em>(Update 2026-06-19: Phase 2 is now COMPLETE. Its one remaining item,
the optional <code>mail-backend-search</code> webmail search UI, is deferred
Post-v10 β the backend primitive is already done
(<code>MailBackend::search</code>, implemented by both <code>MailSiteMailBackend</code> and
<code>ImapMailBackend</code> over the storage <code>searchMessages</code>); only the webmail
search box, the <code>userMailSearch</code> handler, and results rendering
(~150 lines) remain. It now lives with the other MailSite webmail
follow-ups (54/56/57/58) under Post-v10.)</em></p>
<p>Step 1 (config-only, no behaviour change):</p>
<ul>
<li><span class="check">✓</span> <strong>49 β Mail Services fieldset in Server Settings</strong></li>
</ul>
<p>Step 2 (UI affordance, no listener):</p>
<ul>
<li><span class="check">✓</span> <strong>50 β SocialcontrolsElement Mail button + userMail
activity stub</strong></li>
</ul>
<p>Step 3 (external IMAP first, validates the UI):</p>
<ul>
<li><span class="check">✓</span> <strong>51 β ImapClient class</strong></li>
<li><span class="check">✓</span> <strong>52 β Mail activity: external-IMAP accounts UI</strong></li>
</ul>
<p>Step 4 (fill MailSite storage from disk, still no listener):</p>
<ul>
<li><span class="check">✓</span> <strong>48 β Mail import CLI tool</strong> (closed 2026-06-10).
Streaming/resumable/idempotent import, per-folder subscribe +
flags-snapshot priming, per-folder UIDs, progress + timestamps;
used to migrate the production <code>cpollett</code> mailbox. Both Maildir
and mbox sources land; mbox <code>export</code> added. The tool was trimmed
to its durable command set <code>import</code>/<code>export</code>/<code>verify</code> β the
pre-production-only <code>migrate</code>/<code>reuid</code>/<code>cleanup</code> subcommands (and
their <code>moveDirFiles</code>/<code>cleanupFolder</code> helpers) were removed as
one-time states no current or future install reaches. Optional
live-IMAP source deferred (post-v10 nicety). See the status note
under item 48.</li>
</ul>
<p>Step 5 (MailSite-stored mail in the Mail activity):</p>
<ul>
<li><span class="check">✓</span> <strong>53 β Mail activity: MailSite-stored mail</strong> (12-patch
migration arc; details under item 53 above. The optional
<code>mail-backend-search</code> handler migration (patch j) is still
open. The patch-12 visibility cleanup (patch i) and the
<code>MAIL_CLONE_JOB.LAST_ERROR</code> column drop are done β the drop
ships as <code>upgradeDatabaseVersion96</code> (table-rebuild with a
probe no-op guard), errors moved to the <code>MAIL_CLONE_ERROR</code>
table in v95, fresh schema in <code>ProfileModel</code> omits the column,
and <code>DATABASE_VERSION</code> is 102 so the migration runs; no code
reads/writes the dropped column.)</li>
</ul>
<p>Step 6 (the actual listener):</p>
<ul>
<li><span class="check">✓</span> 10 (Manage-Machines controls for MailSite: on/off,
ports, status, logging β actually start/stop the listener;
wired in <code>MachineController</code> <code>statuses</code> and <code>log</code> paths
under the <code>MailServer</code> case).</li>
</ul>
<p>Step 7+ (everything else):</p>
<ul>
<li>
<p><span class="check">✓</span> 55 (MailSite outbound sending β landed during phase 2
ahead of the post-v10 schedule. Local recipients delivered
via <code>MailSite::deliverMail</code>; external recipients delivered
as peer MTA via <code>SmtpClient</code> with per-domain MX resolution
and port 25β587 fallback; opportunistic STARTTLS on the
outbound leg; sent copy archived to Sent via the MailSite
backend).</p>
</li>
<li>
<p><span class="check">✓</span> 59 (Test Mail Mode + Test Mode Fallback Port: relaxes
compose-form validation, skips MailSite FQDN-reject hook,
enables an optional outbound port-25 fallback to a tunnel
forwarder; replaces an earlier speculative 25β587 fallback).</p>
</li>
<li>
<p><span class="check">✓</span> 60 (<code>smtp_tunnel.php</code> standalone byte-level TCP
forwarder for reaching port 25 from a network egress that
blocks it; lives outside the Yioop tree).</p>
</li>
<li>
<p><span class="check">✓</span> 61 (PHP 8.5 dynamic-property declaration fix on
<code>SmtpClient::$last_response_body</code>).</p>
</li>
<li>
<p><span class="check">✓</span> 13 (refactor outbound mail to share construction code
with MailSite when local-delivery applies; closed 2026-06-14 β
<code>SmtpClient::sendImmediate</code> builds via <code>MimeMessage::build</code> and
SmtpClient is trimmed to the wire exchange; see item 13).</p>
</li>
<li>
<p><span class="check">✓</span> 12 full (RFC 5322 / DKIM / List-Unsubscribe headers) β
done; verified in code 2026-06-19. RFC 5322 message construction
is <code>MimeMessage::build</code>; DKIM signing is <code>SmtpClient::dkimSign</code>
wrapped around that built message in <code>sendImmediate</code>; the
List-Unsubscribe + List-Unsubscribe-Post headers come from
<code>UnsubscribeToken::listUnsubscribeHeaders</code> /
<code>listUnsubscribeHeadersForEmail</code>, attached on group/bulk send
(<code>SocialComponent</code>) and on registration mail (<code>RegisterController</code>).
Delivered by the 2026-06-17 email subscribe/unsubscribe arc (see
<code>devlog/v10/2026-06-17 Email List Subscribe Unsubscribe/plan.html</code>),
which also added the <code>MAIL_SUPPRESSION</code> store + send-path skip, the
web unsubscribe endpoint (<code>ApiController::unsubscribe</code> +
<code>UnsubscribeView</code>), the registration suppression gate + whole-site
signed token with its forgery guard, and the <code>bot</code> mailbox reader
(<code>BulkEmailJob::processBotMailbox</code> / <code>applyUnsubscribeToken</code>) that
acts on the mailto form of the header. Still open from that arc are
reader robustness (transient reconnect+retry, per-UID ratchet) and
account aliases; mail-account drag-drop reorder was already done
(verified 2026-06-07).</p>
</li>
<li>
<p><span class="check">✓</span> 11 (webmail interface built β folders, list,
read/compose/reply, flags, move/delete via MailBackend;
further enhancements filed individually).</p>
</li>
<li>
<p><span class="check">✓</span> 14 (rename <code>MailServer</code> β <code>SmtpClient</code>; done in
phase 0).</p>
</li>
</ul>
<p><strong>Phase 2.5 β Ongoing production reliability</strong> (complete to date;
production-driven, runs alongside the planned phases as live issues
surface). Full per-item detail in the "Production reliability &
streaming" section above; the sub-blocks below carry the finer-grained
hot-fix detail.</p>
<ul>
<li><span class="check">✓</span> 62 β <code>WebSite</code> TCP connection lifecycle made fully non-blocking
(accept + TLS handshake, post-TLS protocol detection, WebSocket
frame reads, HTTP/2 frame reads; H3/QUIC reviewed and already
non-blocking). Arose from the observed pollett.org bursty-hang bug.
<em>(2026-06-07)</em></li>
</ul>
<p><em>Production hot-fixes (2026-06-08, pollett.org)</em> β each shipped as a
single-commit forward patch (see <code>yioop-acme-prod-and-bugs-handoff.md</code>):</p>
<ul>
<li>
<p><span class="check">✓</span> 64 β ACME certificate auto-renewal: <code>AcmeRenewJob</code> MediaJob
(ARI-gated via <code>AcmeManager::renewalWindow</code>, ONE_MONTH expiry
fallback) + in-place <code>WebSite::reloadSecureCertificate</code> on a
once-a-minute cert-watch timer in <code>index.php</code>.</p>
</li>
<li>
<p><span class="check">✓</span> Manage-Machines under the secure server: <code>WebSite</code>
<code>processInternalRequest</code> inherits HTTPS/HOST/PORT so internal machine
self-calls no longer 301-redirect to <code>https://0.0.0.0</code>.</p>
</li>
<li>
<p><span class="check">✓</span> Secure-domains apex suggestion strips leading <code>mta-sts.</code>/<code>www.</code>;
<code>MailSiteFactory::suggestedDnsRecords</code> skips <code>www.</code>/<code>mta-sts.</code> hosts
whose apex is already listed.</p>
</li>
<li>
<p><span class="check">✓</span> 63 β Mail Banner / <code>MAIL_HOST_NAME</code> + suggested PTR.</p>
</li>
<li><span class="check">✓</span> ResourceController large-file serving: full-file and
<code>serveRangeRequest</code> loops drain each block through the atto streaming
flush (H2 DATA frame / H1 chunk) instead of the global flush the atto
buffer ignores β fixes OOM serving a ~696 MB .mp4;
<code>WebSite::writeStreamingHead_H1</code> strips Content-Length when forcing
chunked; plus removal of the dead <code>if ($start = 0)</code> range-bound bug.</li>
</ul>
<p><em>Streaming engine + features (2026-06-09/10):</em></p>
<ul>
<li>
<p><span class="check">✓</span> HTTP/2 + HTTP/3 streaming engine hardening: RFC 7540 outbound
send-window engine, slice-bounded interleave, busy-spin/OOM
drain-check fix, HPACK curly-apostrophe crash fix; H3 connection-level
flow control + handshake-over-TCP complete-write loop.</p>
</li>
<li>
<p><span class="check">✓</span> 65 β Per-domain Appearance.</p>
</li>
</ul>
<p><em>Restart, delivery, spins (2026-06-13):</em></p>
<ul>
<li>
<p><span class="check">✓</span> 66 β Restart Server reliability (bind-retry window + supporting
daemon/boot-drain/status fixes).</p>
</li>
<li>
<p><span class="check">✓</span> 67 β HTTP/2 window-sized span delivery.</p>
</li>
<li><span class="check">✓</span> 68 β Event-loop CPU spins (MailSite handshake, GopherSite +
WebSite timer math).</li>
</ul>
<p><em>Startup, sessions, CLI, resource UI (2026-06-13/15):</em></p>
<ul>
<li>
<p><span class="check">✓</span> 69 β First-run & single-secure-port startup robustness (daemon
survives an unconfigured work directory; single 443 port mode;
max-request-len from <code>post_max_size</code>; first-run restart status URL;
warn on a second index server with an in-use port).</p>
</li>
<li>
<p><span class="check">✓</span> 70 β Command-line <code>restart</code> / <code>status</code> for the running server
(+ cross-user <code>isProcessAlive</code> EPERM fix; builds on 66).</p>
</li>
<li>
<p><span class="check">✓</span> 71 β Headless <code>ConfigureTool</code> CLI (<code>work-dir</code>, <code>root-password</code>,
<code>locale</code>, <code>debug</code>/<code>search-access</code>/<code>page-elements</code> toggles,
<code>name-server</code>, <code>robot</code>) β configures a fresh install with no
browser.</p>
</li>
<li>
<p><span class="check">✓</span> 47 β Database-backed session restore (also the Phase 3
random-logout item): <code>SESSION_USER</code> table + in-memory LRU heap with
DB rehydrate, DB version 104β105, so sessions survive a server
restart / memory eviction.</p>
</li>
<li>
<p><span class="check">✓</span> 13 β Outbound mail construction dedup (<code>SmtpClient::sendImmediate</code>
β <code>MimeMessage::build</code>; SmtpClient trimmed to the wire exchange).</p>
</li>
<li>
<p><span class="check">✓</span> large whole-file download returns a full <code>200</code> over HTTP/1.1
(closes the item-67 spot-check).</p>
</li>
<li>
<p><span class="part">▮</span> 17 β Page-resource folder UI: drag-drop, multifile/folder upload
with progress, mail-style action toolbar, inline rename, parent-row
move (incl. to root), single-resource delete, and the VersionManager
stranded-lock root-cause fix all landed; multi-select + keyboard
shortcuts and the CSS-Grid auto-fill rewrite remain (kept open under
item 17 / Phase 7).</p>
</li>
</ul>
<p><em>Group visibility + per-domain robots (2026-06-16/17):</em></p>
<ul>
<li><span class="check">✓</span> 72 β Group page source / history / list visibility master (OPTIONS
bitfield + <code>RENDER_ENGINE</code> column split, migrations v106 / v107) and
per-domain robots.txt / humans.txt (<code>RobotsTxtGenerator</code> +
<code>DOMAIN_APPEARANCE</code> storage + <code>src/index.php</code> host routes + a
Server-Settings edit page with Preview).</li>
</ul>
<p><strong>Phase 3 β Auth modernisation</strong> (complete)</p>
<ul>
<li><span class="check">✓</span> 32, 33(b), 33(c)</li>
<li><span class="check">✓</span> 47 (database-backed session restore β landed 2026-06-14, logged
under Phase 2.5; the per-session store also serves the
"log out everyone" / email-one-time-login needs of 32-35)</li>
</ul>
<p><strong>Phase 3.5 β Cooperative atto Audit and MailSite Daemon</strong> (complete; 2026-06-20 β 2026-06-21)</p>
<ul>
<li><span class="check">✓</span> Audited the atto servers for non-blocking behaviour and made the
MailSite daemon's reactor loop cooperative in four slices (the
<code>WebSite</code> server was already cooperative from Phase 2.5 item
62). Full per-slice detail in the Phase 3.5 arc plan.</li>
<li><span class="check">✓</span> 50, 54, 55 (mail UI / outbound items completed alongside this arc)</li>
</ul>
<p><strong>Phase 3.75 β Authentication Controls and Password Policy</strong> (complete; 2026-06-25 β 2026-06-29)</p>
<ul>
<li><span class="check">✓</span> Rename the Recovery fieldset to Authentication; add a Yioop / LDAP
sign-in-method dropdown.</li>
<li><span class="check">✓</span> Yioop method: configurable password-rule checkboxes + a minimum-length
field.</li>
<li><span class="check">✓</span> Shared, improved password-strength check (JS + server) on every
password entry point.</li>
<li><span class="check">✓</span> Move LDAP settings into the panel (suffix, base DN, host);
<code>LDAP_LOCAL_USER</code> becomes a <code>SigninModel</code> method;
one email per account.</li>
<li><span class="check">✓</span> Root-email LDAP probe before committing the switch (no admin
lockout).</li>
<li><span class="check">✓</span> Account Recovery label + recovery dropdown below the auth controls;
LDAP forces No User Password Recovery Link.</li>
</ul>
<p><strong>Phase 4 β Security audit + wiki regex</strong> (closed 2026-07-10; started 2026-06-29)</p>
<ul>
<li><span class="check">✓</span> 31, 16. Phase 4.1 (auth, cookie, and
header hardening) landed; see above. Phase 4.2 (item 16) then covered a
tokenizer and parser for both the mediawiki-style and markdown engines,
with a golden regression corpus in place. The PHP parser is done for both
engines, the old regex engine is gone, markdown has its own help page, and
the design is ported to JavaScript (help.js) with per-case JavaScript unit
tests. Phase 4 closed 2026-07-10. See the Phase 4 arc plan for the item
breakdown and for the “Other work” delivered alongside it.</li>
</ul>
<p><strong>Phase 4.5 β Settings refactor + live-site fixes</strong> (in progress; started 2026-07-10)</p>
<ul>
<li><span class="box">☐</span> Three threads that do not belong in the
Phase 5 layout work. (a) Live-site crashes: the remaining yioop.com
out-of-memory attention beyond the Phase 4 defensive clamp, and a
pollett.org MailServer.php crash. (b) Split the single Server Settings
screen into two activities: Servers keeps the machine-level groups (Name
Server, Database, Web Server, Mail Services, Proxy), and a new User, Roles,
Groups screen takes Account Registration, Monetization, Bot Configuration,
and Git Wiki Pages, plus two new groups — Role Group Limits (per-role
caps on group count and size, an account's cap being the maximum over its
roles) and Moderation (the group-moderation constants, including whether
moderation runs at all). (c) Small interface fixes, starting with the
Manage Crawl start-crawl [+] control whose accessible name renders stacked
on three lines. Threads (a) and (b) are complete and (c) is nearly done,
with the multi-domain hosting item the main piece remaining. See the
Phase 4.5 arc plan.</li>
</ul>
<p><strong>Phase 5 β CSS foundation + viewport</strong></p>
<ul>
<li><span class="box">☐</span> 26, 27, 28, 29, 30, 23, 25 (viewport meta lands here
with the responsive work, not earlier)</li>
</ul>
<p><strong>Phase 6 β Chat refresh</strong></p>
<ul>
<li><span class="box">☐</span> 1, 2, 6, 7, 8, 9</li>
</ul>
<p><strong>Phase 7 β Wiki UX</strong></p>
<ul>
<li>
<p><span class="part">▮</span> 17 (page-resource folder UI β most of it landed 2026-06-14/15,
logged under Phase 2.5; multi-select + keyboard shortcuts and the
CSS-Grid auto-fill rewrite remain)</p>
</li>
<li>
<p><span class="box">☐</span> 18, 19, 20, 21 (async secret-ballot witnesses), 37</p>
</li>
</ul>
<p><strong>Phase 8 β Calling</strong></p>
<ul>
<li><span class="box">☐</span> 3, 4, 5</li>
</ul>
<p><strong>Phase 9 β Crawl / index health</strong></p>
<ul>
<li><span class="box">☐</span> 15, 22, 43 (deprecate IndexShard read path; full removal
may slip post-v10), 45 (decision on crawl mixes)</li>
</ul>
<p><strong>Phase 10 β Final docs pass + release prep</strong></p>
<ul>
<li>
<p><span class="box">☐</span> 34, 36, 42 (localization audit + escape-bug fix), 44 (trim
VersionFunctions to v9+)</p>
</li>
<li>
<p><em>Item 36 (mail-deliverability docs) moved here 2026-06-19: it
documents standards already delivered (DKIM/SPF/DMARC guidance,
Banner / PTR), so it belongs in the final v10 documentation push
rather than a phase of its own.</em></p>
</li>
</ul>
<p><strong>Phase 11 β Deliverability</strong> (the standards work is done β DKIM,
SPF, DMARC, MTA-STS, Banner / <code>MAIL_HOST_NAME</code>, PTR / FCrDNS all
shipped in Phase 2. Its one remaining piece was the documentation,
item 36, which moved 2026-06-19 into the final docs push (Phase 10).
This block is kept only as a record of what landed.)</p>
<ul>
<li>
<p>36 (document mail deliverability requirements: DKIM, SPF, DMARC,
Banner / <code>MAIL_HOST_NAME</code>, PTR / FCrDNS β refresh the PDF
deliverability docs to match the wiki Mail Services page) β moved
to the final docs push, Phase 10.</p>
</li>
<li>
<p>Implementation prerequisite tracked under item 12 (RFC 5322 / DKIM
signing / List-Unsubscribe headers, in Phase 2); document each piece
here as it ships.</p>
</li>
<li>
<p>Already landed toward this (groundwork, for reference): item 63
(Banner / <code>MAIL_HOST_NAME</code> host identity + suggested PTR row in the
Suggested DNS Records panel; the wiki Mail Services page gained a
Banner section and PTR note). The HELO/PTR/FCrDNS half of the
deliverability story is in place; the RFC-5322/DKIM/List-Unsubscribe
half (item 12) and the PDF doc refresh (item 36) remain.</p>
</li>
<li>
<p><em>Log ongoing deliverability work as new bullets here as it lands.</em></p>
</li>
</ul>
<p><strong>Post-v10</strong></p>
<ul>
<li><span class="box">☐</span> 46 (unified search)</li>
<li><span class="check">✓</span> 54 (cross-account mail move) — landed during Phase 3.5</li>
<li><span class="box">☐</span> 56 (MailSite spam classifier)</li>
<li><span class="box">☐</span> 57 (MailSite unread-count caching)</li>
<li><span class="box">☐</span> 58 (MailSite sort/filter parity with IMAP)</li>
<li>
<p><span class="box">☐</span> mail-backend-search (webmail search UI) β deferred from Phase 2
on 2026-06-19. The backend is done (<code>MailBackend::search</code> in both
backends, over the storage <code>searchMessages</code>); what remains is the
webmail search box, a <code>userMailSearch</code> handler that turns matched
uids into the same list/envelope structure <code>MailElement</code> renders,
and routing/locale/test (~150 lines). Belongs with the MailSite
webmail follow-ups (54/56/57/58) above.</p>
</li>
<li>
<p><span class="box">☐</span> 43 follow-up (full IndexShard removal once the deprecation
cycle has shipped)</p>
</li>
<li>
<p><span class="box">☐</span> Mirror sync latent memory bug. <code>ResourceController::syncList</code>
builds the whole delta file-list for a mirror request and serves it
with <code>base64_encode(gzcompress(serialize($info)))</code> echoed
whole, holding the entire list (and its base64 copy) in memory at
once. On a large cache this is the same unbounded-memory exposure the
main resource serve was hardened against (range / offset-limit /
large-file streaming), so a mirror puller over a big cache could
exhaust the process the way the HTTP/2 scrub did. Latent for now
because pollett.org does not run a mirror. Either bound and stream the
sync list (page the delta in fixed spans, like the resource serve) or,
if the Mirror feature is being retired, remove it; surfaced
2026-06-23 during the HTTP/2 connection-memory work.</p>
</li>
</ul>
<h2>Notes / cross-cutting</h2>
<ul>
<li>
<p><strong>Write unit tests as code lands.</strong> Per <code>Coding.pdf</code> Code-base
Organization rule 10, every new <code>library/</code> file should ship with
a sibling <code>*Test.php</code>. Run via
<code>php executables/CodeTool.php unit</code> (lists), <code>unit ClassName</code>
(run one class), or <code>unit ClassName methodName</code> (one method).
<em>Note: <code>UnitTest::setUp()</code> and <code>tearDown()</code> are abstract β must
be implemented even when empty (skipping silently fatals out
with no diagnostic), which masked an earlier test scaffolding
attempt.</em></p>
</li>
<li>
<p><strong>MailSite test seam.</strong> <code>MailSiteFactory::setTestOverride($site)</code>
replaces the factory's <code>build()</code> return value with a
pre-configured <code>MailSite</code> instance (typically wired to
<code>RamMailStorage</code>) so unit tests don't need a WORK_DIRECTORY.
Tests must clear the override in <code>tearDown</code> to avoid bleeding
into other tests. Used by <code>MailSiteMailBackendTest</code>.</p>
</li>
<li>
<p><strong>Stale-item verification (2026-06-07).</strong> A pass over a few
"open" housekeeping items found several already done in the
code, so the list had drifted. Confirmed already complete:
(1) the dead clone locale key <code>mail_element_clone_errors_toggle</code>
does not exist in the locale files β the live keys
(<code>mail_element_clone_errors_none</code>,
<code>mail_element_clone_error_resolved_via</code>, etc.) are all
referenced in <code>SocialComponent</code> and <code>mailclone.js</code>;
(2) the <code>MAIL_CLONE_JOB.LAST_ERROR</code> column drop (see Step 5
note); (3) mail-account drag-drop reorder β <code>reorderAccounts</code>
handler in <code>SocialComponent</code> (with <code>userMailReorderAccounts</code>
persisting the order). Lesson: verify "open" items against the
code before scheduling work on them.</p>
</li>
<li>
<p><strong>Stale-item verification (2026-06-19).</strong> Second pass, same
lesson. Item 12 (RFC 5322 / DKIM / List-Unsubscribe headers) was
still shown <code>- [ ]</code> open but is fully done in code β RFC 5322 via
<code>MimeMessage::build</code>, DKIM via <code>SmtpClient::dkimSign</code>,
List-Unsubscribe via the 2026-06-17 email subscribe/unsubscribe
arc; now checked off above. Re-confirmed already complete and not
to be relisted: the dead clone locale key
<code>mail_element_clone_errors_toggle</code> (absent from <code>src/</code>), the
<code>MAIL_CLONE_JOB.LAST_ERROR</code> drop (<code>upgradeDatabaseVersion96</code>;
<code>MAIL_SCHEDULED.LAST_ERROR</code> and <code>MAIL_CLONE_ERROR.MESSAGE</code> are
unrelated false positives), and mail-account drag-drop reorder.
Rule reinforced: this todo file is the canonical store, not chat
memory; an item only "counts" as tracked once it is checked off
here, so reconcile the todo against the code rather than re-raising
done work.</p>
</li>
<li>
<p><strong>Phase-completion checkpoint.</strong> When we finish all items in a
phase, Chris sends a fresh tarball of the project so Claude can
verify its local working tree is in sync with origin/master
before starting the next phase.</p>
</li>
<li>
<p><strong><code>@return</code> tag style.</strong> Keep tags concise: type plus a brief
prose phrase. For functions that return a value on some paths
and fall off the end on others, write
<code>@return <type> <description of the failure value>; void on
the success path</code>. For activity methods whose only <code>return</code> is
a <code>redirectWithMessage(...)</code> call, the established Yioop
convention is to omit <code>@return</code> entirely.</p>
</li>
<li>
<p>The Public, Help, Search built-in groups + per-user
<code>Personal$<id></code> groups underpin chat, clipboard, and
context-sensitive help. Touching any chat / wiki-resources /
secret-ballot items must preserve this model.</p>
</li>
<li>
<p>All end-user-facing strings need to go through <code>tl()</code> /
<code>LocaleModel.php</code>. Code comments and log messages stay English.</p>
</li>
<li>
<p>Per <code>Coding.pdf</code> general rules: minimise external deps; UTF-8 no
BOM; 4-space indent; β€80 col lines; phpDoc on public surfaces;
no <code>@</code> error suppression.</p>
</li>
<li>
<p><strong>Apache vs WebSite-mode hazards.</strong> Yioop runs under both
Apache (mod_php / php-fpm β output buffered, <code>header()</code> and
<code>exit()</code> work normally) and atto's WebSite (long-running
server, response stream starts early). Handler code that
emits non-HTML output must use <code>$parent->web_site->header()</code>
(delegates to plain <code>header()</code> under Apache) and
<code>\seekquarry\atto\webExit()</code> (falls back to real <code>exit()</code>
under Apache) rather than the bare PHP calls. Pre-MailSite
testing was under Apache; the same code paths under WebSite
warn-and-misbehave. Sweep done across <code>SocialComponent</code> JSON
handlers; other controllers may still have stale patterns.</p>
</li>
</ul>
</body>
</html>