September 27, 2026
Mercury v1.0.0
Added
-
VisibleDashboard Each sender-list entry, pattern and content rule on the Policy view shows how many messages it decided in the last 90 days and when it last did. An entry with no hits is marked, so a rule that no longer fires is easy to spot. (#75)
-
VisibleDashboard A Preview button on the sender-list and pattern forms runs the entry against the last 90 days of mail before it is added, and says how many messages it covers and how many would have been decided differently. Content rules are not previewed, since only the judge can say whether one applies. (#75)
-
VisibleDashboard A Review view draws a random sample of recent accepted, deferred and rejected mail for marking right or wrong, and shows the error rate for each outcome from those labels. Removing a rule from a message’s panel also marks that message wrong, counted apart from the sample. Apply
worker/schema.sqlfor the newreview_labelstable. (#75) -
VisibleDashboard Activity has a search box for sender and subject, which the / key jumps to, plus buttons that filter by outcome. A percent sign or underscore in a search matches literally.
-
VisibleDashboard A pill in the header says whether mail is flowing and turns red when the latest delivery to the mailbox failed. The System view lists when the last message arrived, the last delivery, the last failed delivery with its error, and the last retention sweep. The Overview splits the last 24 hours into accepted, deferred and rejected mail and counts failed deliveries.
-
InternalFiltering A second structured-judge backend, configured with
LAYA_URL: any server that accepts the same/v1/systemonerequest, such as a self-hosted open-weight model. It answers every message in the background and decides nothing. It takes 4 to 8 s for the full question set against about 0.3 s for the primary, so no message waits for it except during a failover. Each pair of answers is appended to/data/laya_shadow.jsonl(answers only, never message content), andbackend/laya_shadow.pyrefits a calibration from them at startup and every six hours in a separate process. The primary’s answers are the soft targets. Nouls get Platt scaling, while verdict, category and severity get a temperature plus a per-option bias, which can correct a systematic confusion such as PHISH read as SPAM. Confidence is recomputed the way the primary defines it, so the thresholds inverdict_policy.pycarry over. Readiness is scored out of fold on dispositions: 50 rows, 95% agreement with the primary, and at most 1% bounces of mail the primary did not bounce. WithLAYA_FAILOVER=trueand a ready calibration, a primary outage is decided by the calibrated backend instead of the language-model judge.GET /laya/statusreports the metrics. (#62) -
InternalFiltering A Deliver, Soft-bounce or Hard-bounce tap on a Telegram verdict report is recorded against that message’s shadow row as a human label, and the readiness metrics report both backends’ accuracy on those labels. (#62)
-
VisibleFiltering A semantic rule proposed in a Telegram brief is tested against the brief’s own message before it is shown for approval, using the pipeline’s own rule-match question. A rule that would not fire, that fires below
MERCURY_RULE_CONFIDENCE, or that loses to an existing rule gets a caveat saying so, with the number. (#55) -
InternalFiltering A fourth provider seam,
backend/providers/structured_judge.py, answering the judge’s classification questions as typed values with calibrated probabilities. It runs alongside the judge on the same message and logs ajudge_comparisonsrow whenever the two disagree. Agreement is not logged. Gated behindSTRUCTURED_JUDGE_ENABLED, and decides nothing untilSTRUCTURED_JUDGE_AUTHORITATIVEis set too. The judge still writes the reasoning sentence; this seam’s model generates no text. Standing rules are selected by id, so a paraphrased or invented rule cannot reach the policy. Applyworker/schema.sqlfirst. -
InternalFiltering
backend/sender_facts.pycomputes the sender’s TLD, whether it is a foreign country code, and how the recipient was actually addressed, and hands them to the structured judge as observed facts. Two standing rules previously asked the classifier to decide these, which is a model guessing at a string comparison; both have been retired. Neither became a deterministic bounce: a Bcc has no visible recipient by design, and_classify_recipient()returns nothing whenever the webhook payload lacks usable To/Cc data, which is true of 564 of 570 accepted messages in the log. A foreign TLD likewise catches a Japanese support desk as readily as a spam campaign. Measured across four representative messages, supplying the facts changed no disposition; the retirement of the rules is what mattered. -
InternalFiltering The structured judge retries once on 429 and 529 rather than falling straight back to the judge alone. A real 529 from the service was observed during development.
-
InternalFiltering
backend/verdict_policy.py, holding the thresholds that turn a structured verdict into a disposition and an alert level. Each one is overridable by environment variable. Accepting is the default and nothing has to earn it: only a confident SPAM or PHISH verdict, threat evidence at or aboveMERCURY_DEFER_SEVERITY, or a lean toward one of those aboveMERCURY_DEFER_CONFIDENCEmoves a message off 250. Confidence is not a legitimacy score, and requiring it before accepting mail held 44 of 45 messages this mailbox had really accepted: a bank statement notice, a credit alert and a newsletter all sat between 0.50 and 0.60, because phishing imitates exactly those. The severity cutoff sits above everything those 45 produced, the highest being 1.81. An accepted message can no longer page Telegram under any combination of signals. -
VisibleDashboard The Recent activity and Hard bounces tables gained a Recip. column showing how the message was actually addressed:
R(an rpgm.tools address visible in To/Cc),F(a personal address that forwards into rpgm.tools visible in To/Cc),r(Bcc’d straight to an rpgm.tools address), orf(Bcc’d on a personal address before it was forwarded in). A tooltip on each badge names the exact matched address and which header it was found in, or which envelope alias the message arrived on for a Bcc. Computed at ingest time from ForwardEmail’s own parsed To/Cc fields and its webhook session recipient; only applies to messages ingested from here forward. -
VisibleFiltering Blacklist entries can now be regex patterns, in addition to exact addresses and domains. A pattern is matched full-string against the sender domain only, is validated (compiled) when it is added, and is checked only after all three exact sender lists find no match, so an exact whitelist or greylist entry always takes precedence over a pattern that would otherwise also catch that domain. Covers rotating spam campaigns that share a domain-naming shape (a random numeric prefix plus a word before the TLD) without needing a separate blacklist entry per domain. A pattern can only bounce (550), never greylist or whitelist.
-
VisibleFiltering The open-ended brief judge can now propose a BLACKLIST_PATTERN change alongside SENDER_LIST, for a recipient describing a rotating spam domain shape rather than one sender. Same validation and precedence rules as a dashboard-entered pattern.
-
VisibleThunderbird The flagging popup gained four quick-action buttons above the instruction box - Unsubscribe, Bounce Domain, Bounce Pattern, and Custom - each filling in a starting instruction from the flagged message’s own sender that the recipient can still edit before sending. All four go through the same instruction box, submission, and Telegram approval flow as typing one by hand (v0.3.6).
-
VisibleThunderbird The flagging popup’s flagged-message text gained an (x) to remove it from the request, for an instruction that is a standing policy change rather than about that particular email. Unsubscribe and Bounce Domain are disabled once removed, since both need a real sender; Bounce Pattern and Custom keep working (v0.3.7).
-
VisibleThunderbird Composing a new message, or forwarding one, now pops up a small prompt asking which username to send as (default: the account’s own username), combined with the domain to form the From address. Replying instead sets the From address automatically, with no prompt, to whichever domain alias the original message was actually sent to, falling back to the same prompt when no such alias can be found in the original’s headers. The account’s own bare inbox address is never used as an outgoing From (v0.3.9).
-
VisibleDashboard A message bounced by a blacklist pattern now names the pattern in its reasoning and saves it as the message’s triggered rule, so the existing “Reverse this rule” button on a hard-bounce’s detail view removes the pattern too, the same way it already removes a bad semantic rule.
Changed
-
InternalThunderbird Mercury has one version for the backend, the Worker, the gateway and the Thunderbird extension, starting at 1.0.0. Releases are tagged vX.Y.Z, and the extension’s version matches the product’s. Each GitHub Release carries that version’s changelog section as its notes. The older thunderbird-vX.Y.Z tags remain as the extension’s history.
-
InternalFiltering
judge_comparisonsgets a row when the two judges agree too, with an empty field list, so the System view can show how often they disagree. (#75) -
InternalFiltering Laya’s readiness metric
false_bounce_rateis renamedextra_bounce_rate, since it counts bounces the primary did not make rather than mistakes./laya/statusalso reports the false-bounce rate of each backend on human labels.LAYA_READY_MAX_EXTRA_BOUNCEsets the threshold, and the old variable name still works. (#75) -
InternalDashboard The Worker checks the token Cloudflare Access attaches to every dashboard request: its signature against the team’s published keys, the application’s audience tag, the issuer and the expiry. A request that reaches the Worker without passing Access, or before
ACCESS_TEAM_DOMAINandACCESS_AUDare set, gets 403. (#73) -
VisibleDashboard The dashboard is split into five views: Overview, Activity, Policy, Audit and System. The view and any Activity filter are kept in the address, so a link or a reload returns to the same place. It follows the system light or dark theme unless one is picked in the header, and it refreshes every minute while the tab is visible.
-
VisibleDashboard Any message opens in a side panel showing its verdict, reasoning, recipient, injection check, saved content and the rule that decided it, with a button that removes that rule. Before, only hard bounces could be opened.
-
VisibleDashboard Removing a policy entry asks first in a dialog that names the list. A search box above the policy filters every list at once. The sender lists are now called Always accept, Always defer and Always reject, with their SMTP codes beside them.
-
VisibleThunderbird The extension signs in with its own secret,
MERCURY_EXTENSION_SECRET, which the Worker accepts only for rule proposals, instead of the backend’sMERCURY_SHARED_SECRET. Set the new secret on the Worker and paste it into the extension’s options. Until then, the old shared secret keeps working there. -
InternalDashboard Dashboard pages are no longer cached, can’t be framed by another site, and carry a Content Security Policy. A POST whose Origin is not the dashboard’s own gets a 403. The message list no longer sends the saved body and analysis of hard bounces, which only the message panel reads.
-
VisibleTelegram Only a 421 soft deferral sends an individual Telegram report. Hard bounces and accepted mail show up in the dashboard and the daily digest instead.
-
VisibleFiltering The Telegram brief now writes a proposed semantic rule as a full sentence describing what a matching message says or asks for, not a bare label. A label such as “Political fundraising” lost to no-match on campaign mail that never used the word political, while the sentence form matched it. Together with the pre-approval self-test, this closes #55. (#55)
-
InternalFiltering With the structured judge authoritative, the language-model judge runs only for a 421 or 550 decision. Accepted mail never pages, so its reasoning is the line built from the structured answers, and it gets its SMTP response without waiting on the gateway. (#61)
-
VisibleFiltering When the language-model judge’s own verdict or disposition disagrees with the enforced one, the report leads with the structured line and quotes the judge’s sentence as a dissent. Before, a bounced message could carry a sentence arguing that it was ordinary mail. (#61)
-
VisibleDashboard The browser dashboard no longer authenticates with a shared HTTP Basic Auth password. A Cloudflare Access application on mercury.rpgm.tools/dashboard* now gates entry by email identity instead, reusing the same reusable Access policy already used elsewhere. The daily digest email’s own automated fetch of the same routes (backend/digest.py) switched from that shared password to a Cloudflare Access Service Token (CF-Access-Client-Id/CF-Access-Client-Secret headers), authorized through a Service Auth policy on the same application rather than a second human credential. The Worker no longer holds or checks a DASHBOARD_PASSWORD secret at all.
-
VisibleFiltering A domain sender-list or custom-action entry now also covers its own subdomains, at any depth - whitelisting paypal.com now also covers a sender at billing.paypal.com. When entries in different lists both cover a sender’s domain, the longer, more specific entry wins, the same way an exact address already outranks any domain entry. Address selectors are unaffected; they only ever match themselves.
-
VisiblePipeline The prompt-injection classifier reference server now runs
meta-llama/Llama-Prompt-Guard-2-86Minstead ofprotectai/deberta-v3-base-prompt-injection-v2. The previous model false-flagged ordinary transactional email (password resets, account notices) asINJECTION; the new one was benchmarked clean against the same failing samples plus a real-attack sample, with no false positives either direction. The HTTP contract ({"label": "SAFE"|"INJECTION", "score": <float>}) andPROMPT_INJECTION_CLASSIFIER_URLare unchanged, so this is a drop-in swap for any consumer. -
VisiblePipeline A message the judge accepts outright (disposition 250) no longer pages Telegram on the judge’s own subjective alert level alone. Only a soft-defer (421), a hard-bounce (550), or a real prompt-injection finding can still trigger a STANDARD or URGENT ping; an accepted message now stays silent regardless of category or how confident the judge’s wording sounded, covered by the daily summary and dashboard instead. Fixes a recurring false-alarm pattern where routine account-security notifications (e.g. a legitimate “new login” email) paged Telegram despite a LEGIT verdict and a SAFE injection screen.
Removed
- VisibleDashboard The Hard bounces table and its API are gone. Rejected mail is one filter on Activity, listed in order with everything else. (#65)
Fixed
-
InternalDigest The digest reads its Access service-token values without the
CF-Access-Client-Id:orCF-Access-Client-Secret:label that Cloudflare shows beside them. The stored values carried that label, so Access never accepted the token and the digest could not read the dashboard API. (#74) -
InternalPipeline The agent gateway listens on 127.0.0.1 unless
AGENT_GATEWAY_HOSTis set, and the backend reaches it through host.docker.internal. It refuses a request body over 1 MB, and a command that exits non-zero or prints nothing gets 502. Before, an empty reply reached the judge parser and was accepted. (#71) -
VisibleFiltering When the free-text judge names a verdict other than LEGIT but gives no disposition, the message is deferred with 421. It was accepted before. (#70)
-
InternalTelegram The raw message an open Telegram brief keeps for a Deliver decision is encrypted in
pending_approvals.json, dropped after seven days, and the file is written owner-only. An existing plaintext copy is encrypted the next time the file is written. (#72) -
VisibleDashboard The counts and charts for the last day, the last week and the last 30 days now cover exactly that span. Stored timestamps were compared as text against a different format, so every message from the whole cutoff date was counted, and the daily message card could show nearly twice the real number. Retention deletes were off by the same day.
-
VisibleDashboard A panel whose data fails to load says so, instead of showing an empty table or “No open action items”. Dashboard API errors now return HTTP 500, and a failed Complete on an action item shows why.
-
VisibleDashboard Standing rules counts the rules in the live policy. It was rebuilt from the change log, where an add and its dashboard reversal were logged under different text, so the count only grew.
-
VisibleDigest The daily digest builds again. It read the paginated dashboard endpoints as plain lists and failed on every run once pagination landed. It now pages through each one until it covers the last 24 hours, and takes the standing rule count from the live policy. (#66)
-
VisiblePipeline Accepted mail that Mercury cannot deliver is deferred instead of lost. With
MERCURY_DELIVER_ACCEPTED_MAILon, a failed IMAP APPEND, a repeat call while the first is still running, a pipeline error, and an unreachable backend now answer 421, so the mail host retries. All of these answered 250 before, which ended delivery with nothing in the mailbox. The retry repeats only the APPEND, and a message appended before a later step failed is not appended again. A payload with no raw message is still accepted, since a retry could not deliver it, and now sends a Telegram alert naming it. SetCUSTODY_REQUIRED=trueon the Worker alongside the backend flag. -
VisiblePipeline The webhook URL now ends in a secret token,
MERCURY_WEBHOOK_TOKEN, and a call without it gets a 421, so a mistyped URL defers mail rather than bouncing it. Set the token as a Worker secret and change the webhook URL at the mail host;WEBHOOK_ALLOW_TOKENLESS=truekeeps the old path open during the switch. -
VisibleTelegram A Gandalf handoff that names a hostname blocked by ForwardEmail’s link filter is sent again with that hostname written as
host[.]tld, instead of failing with “Could not reach Gandalf”. A failed handoff now logs the SMTP reply. (#64) -
VisibleTelegram Approve, Discard and decision buttons on a Telegram card now take effect. The Hermes gateway holds the bot’s only getUpdates connection, and its relay plugin had never forwarded a tap. A relayed update from any other chat is ignored, and so is a relayed reply to a message Mercury did not send, so a reply meant for Loremaster cannot approve an open brief. (#63)
-
InternalFiltering A language-model judge error or timeout no longer discards the structured verdict. The exception used to escape
asyncio.gather()and fail the whole message open to 250 with a pipeline-error page, even when a confident bounce had already come back. (#61) -
VisiblePipeline An accepted message (disposition 250) was being reported back to the original sender as a hard bounce, ever since Mercury first went live - ForwardEmail’s own webhook error-code translation only recognizes a thrown status in the 400-599 range as a real SMTP reply, so 250 fell through to that logic’s unconditional default of 550 and told senders their mail failed even though it was delivered correctly. Soft-defer (421) and hard-bounce (550) were never affected, since both already fall in that range and round-trip to the sender correctly. An accept is now signaled to ForwardEmail as a literal HTTP 200 instead of the raw disposition code.
-
VisibleThunderbirdPipeline The unsubscribe agent submitted the wrong recipient email for an account with more than one identity: when no message header matched a known identity, the flagging popup fell back to the account’s first identity rather than admitting it did not know which one was actually meant, and that guess was wrong for a message sent to a different identity on the same account. It now sends no address at all in that case, so the backend reports FAILED and asks rather than acting on a guess - a wrong address submitted to a real unsubscribe form is a wrong action taken silently, not a missing one (v0.3.19).
-
VisiblePipeline The agent gateway’s own subprocess timeout defaulted to 60 seconds, far shorter than a real browsing task (checking a route’s domain, visiting it, filling a confirmation form) can need, and shorter than the backend’s own 180-second client timeout - so a genuinely slow but legitimate agent call died with a 504 well before the backend would have given up waiting. Raised to 240 seconds on the gateway and 300 on the backend client, keeping the gateway’s own timeout safely below the client’s so a slow call still gets the gateway’s informative 504 rather than the client’s blunter connection-level timeout.
-
VisiblePipeline An approved action’s own failure (an agent-gateway timeout, a malformed judge response) used to propagate into
poll_forever’s blanket per-update exception handler, which silently slept and moved on - no log line, no Telegram message. The recipient’s last signal was a live “working on it” progress update that just stopped forever, with the brief stuck open on its stale original action._approvenow reportsAction failed: ...back to Telegram and leaves the brief open on its original action so approving again retries the same thing;poll_forevernow logs before sleeping so a future failure at least shows up in the container logs. -
VisiblePipeline A reply whose
reply_to_messagedoesn’t resolve to a tracked brief - too old to still be in the message index, or a reply to something a different Telegram participant said rather than Mercury itself - used to be dropped outright. It now falls back to whatever brief is genuinely still open, rather than the reply vanishing with no effect and no acknowledgement. -
VisiblePipelineThunderbird The unsubscribe agent can now fill in the recipient’s own subscribed email address when a confirmation page asks for one before it will process the request - common on Mailchimp and similar list-management providers. The flagging popup captures the account’s own address (matched against the flagged message’s Delivered-To/To/Cc headers, falling back to the account’s default identity) and sends it alongside the flagged message; the browsing prompt is told it is safe to type into an email-confirmation field and is never a credential. Previously the agent had no such address at all and could only report
FAILED(v0.3.18). -
VisiblePipeline The bounce-decision followup (“Also add this domain to the blacklist?”) is now offered only when the outcome’s own
recommendationishard(a route rejected as unsafe), rather than for every outcome that named a domain. A benignFAILED- such as an unsubscribe form that needed an email address that was not available - no longer prompts to blacklist what may be a perfectly legitimate sender. -
VisiblePipeline An approved unsubscribe no longer terminates as
SKIPPED_UNSAFEfor a sender whosetext/plainalternative is a link-stripped rendering of the HTML. Such a part keeps the anchor text and drops every URL, so a footer readingUnsubscribe | Update Profilewas the only trace of a route that really existed, and neither thetext/htmlpart carrying thehrefnor theList-Unsubscribeheader ever reached the judge. Routes are now extracted from the headers and link targets before the prompt is built and stated in a labelled block ahead of the body, so the per-message size cap cannot remove them either. Quoted-printable soft line breaks are rejoined first, which repairs URLs previously recorded with a truncated host such asmanage.kmail-. Extraction feeds only the action context: the dedup key, the injection classifier, and the judge’s verdict still read the unmodified message. -
VisiblePipeline A message with no unsubscribe route at all now reports
FAILEDrather thanSKIPPED_UNSAFE, which previously conflated finding nothing with rejecting something. -
InternalThunderbird The flagging popup now sends the
text/htmlpart and theList-UnsubscribeandList-Unsubscribe-Postheader values alongside the plain-text body, instead of discarding everything but the firsttext/plainpart. Inline CSS, scripts, and comments are stripped first, and an over-budget document keeps its tail rather than its head, since an unsubscribe footer sits at the end (v0.3.17). -
InternalThunderbird Removing the tabs permission (below) turned out not to be the whole story - the “module is not a constructor” crash stopped, but reply still triggered nothing. Checked Thunderbird’s own current API documentation instead of continuing to guess: there is no dedicated “a compose window was opened” event in the compose API at all, and the pattern actually documented to work is windows.onCreated, checking the WINDOW’s own type and then querying its tabs - not tabs.onCreated, which every previous attempt here used instead. Added windows.onCreated alongside the existing tabs.onCreated/onUpdated listeners (v0.3.16).
-
VisibleThunderbird Found it: opening a reply threw “module is not a constructor” out of Thunderbird’s own WebExtension API loader (ExtensionCommon.sys.mjs’s asyncGetAPI), an uncaught exception in Thunderbird’s own machinery that happened before any of this extension’s code ever ran - which is why nothing was ever logged for reply, at any logging level. Removed the
tabspermission added speculatively in v0.3.12: it was never actually required (tabs.onCreated/onUpdated/onRemoved fire without it; that permission only gates sensitive properties like url/title, not type), and its presence is what triggered the crash (v0.3.15). -
InternalThunderbird v0.3.13’s diagnostic logging used console.log, which Thunderbird’s Browser Console filters behind its own “Logs” toggle (off by default) separately from “Warnings” and “Errors” - so a report of “nothing at all logged” was consistent with the code never running, but also with it running fine and simply being filtered out with no indication that was happening. Switched to console.warn, which isn’t gated behind that toggle (v0.3.14).
-
InternalThunderbird Reply still triggers nothing at all - neither the prompt nor a logged error - even after the tabs.onUpdated and fail-safe changes below, and with no logging in place there was no way to tell whether either listener even fires for it. Added logging (temporary, meant to be trimmed once this is actually diagnosed) to every tabs.onCreated fire, every tabs.onUpdated fire that touches a tab’s type, and every compose-details read, so the next attempt’s console output shows what’s actually happening instead of another guess (v0.3.13).
-
VisibleThunderbird Replying still never triggered the sending-address prompt or an auto-matched From even after v0.3.11’s tabs.onUpdated fix. Reading a reply’s compose details could keep failing for longer than the short poll window allowed for, and that failure silently gave up instead of falling back to the prompt - the one outcome that must never happen, since it’s what leaves the bare address in place. The poll window is longer now, and failing to read compose details at all (or failing to resolve a recognized type) now still opens the prompt rather than doing nothing. Also added the
tabspermission, to remove any doubt about whether a tab’stypewas visible to the extension without it (v0.3.12). -
VisibleThunderbird Replying still never triggered the sending-address prompt or an auto-matched From, even after v0.3.10 fixed it for new messages and forwards. Replying from an already-open message tab converts that same tab into the compose editor in place instead of creating a new one, so tabs.onCreated - which only fires for a genuinely new tab - never saw it. tabs.onUpdated, watching for a tab’s type changing to “messageCompose”, now catches this case too, alongside onCreated for the new-tab case (v0.3.11).
-
VisibleThunderbird The sending-address prompt introduced in v0.3.9 had four problems: it never appeared at all when replying or forwarding, since the compose.onComposeStateChanged event it relied on only fires on a later edit and never fired for a composer that was already fully populated the moment its tab existed; the popup window was too short for its own content; submitting it failed with “Could not establish connection” because it round-tripped the chosen address through background.js’s runtime messaging instead of setting it directly; and it could not be dismissed with Escape. The trigger is now tabs.onCreated (fires once, reliably, for every new compose tab of any type), the popup sets the From address itself, the window is taller, and Escape closes it (v0.3.10).
-
VisiblePipeline A repeated /ingest call for the same message - a retried webhook after a slow response, or two independent deliveries of it - was reprocessed from scratch every time: on an accepted message this appended a second copy into the mailbox via IMAP, and since the semantic judge is a live model call, a repeat was not guaranteed to reach the same verdict as the first call, so an already-delivered message could come back recorded as bounced. /ingest now keys a persisted dedup store off the message’s own Message-Id (falling back to a content hash when one isn’t present) and replays the first call’s outcome for any repeat within a bounded retention window instead of re-running the classifier, judge, or delivery steps.
-
VisibleFiltering An open-ended brief instruction whitelisting or blacklisting several domains in one turn (“whitelist paypal.com and gog.com”) only ever produced one sender-list entry - the SENDER_LIST response format had room for a single selector, so the judge silently dropped the rest rather than proposing all of them. It now accepts a comma-separated list of selectors under one disposition and turns each into its own separate policy entry.
-
VisibleTelegram A verdict report’s fourth decision button always read “Deliver + whitelist” even when the message had already been delivered (250), where a tap could only ever do the whitelist half. It now reads “Whitelist” in that case, and a new fifth “Do nothing” button resolves a report that turned out not to need any standing change or action.
-
VisibleThunderbird Right-clicking a message-list selection with nothing open in the reading pane and choosing “Flag for Mercury” did not open a usable request - the popup was looking at the displayed message, not the actual right-clicked selection, so it found nothing when the two differed. The context-menu click now hands its selection to the popup directly (v0.3.8).
-
Initial scaffold: Cloudflare Worker gate, FastAPI backend, prompt-injection screening via a local classifier, redaction of the mailbox owner’s own addresses, semantic verdict via model call, Telegram shadow reports.
-
Shadow mode only - no message is ever blocked or bounced yet.
-
Alert on pipeline failure instead of failing silently.
-
Semantic verdict now goes through a configurable agent gateway instead of a bare model call, and includes a recommended disposition (250/421/550) alongside the verdict - not yet enforced.
-
First rules-ledger entry: lewd/dating-site content recommends a hard bounce.
-
Live mail now flows through the pipeline in shadow mode, not just a test alias.
-
Refactored the backend around three explicit provider seams (
backend/providers/: classifier, judge, notifier), each a small interface with one built-in implementation, so any of the three can be swapped independently. Renamed the environment variables accordingly and removed every hardcoded hostname/IP default. -
Genericized the agent gateway (
gateway/agent_gateway.py, wasloremaster-gateway.py): it now shells out to whatever commandAGENT_CHAT_COMMANDnames, instead of a hardcoded CLI path. -
Fixed the Worker gate always forwarding to a hardcoded
/ingestpath and discarding the backend’s real response - it now forwards each request’s own path and, outside the mail-webhook path, proxies synchronously and returns the backend’s actual response. -
Added
/rules/proposeand a Thunderbird extension (thunderbird/): flag the open message, describe in plain language how it and similar messages should be handled, and the instruction is interpreted into a rules-ledger entry via the judge provider and appended immediately (reported via the notifier, not yet gated behind a confirmation step). -
worker/wrangler.tomlis now gitignored (a.exampletemplate is committed instead) so a real deployment’s hostnames are never committed. -
Fixed the Thunderbird popup silently doing nothing on click: it looked up the active message via
tabs.query({currentWindow: true}), which does not reliably resolve to the 3-pane window’s displayed message from inside a message-display-action popup, so the lookup failed before the button’s click handler was ever attached. It now asksmessageDisplay.getDisplayedMessage()for the active tab directly, andinit()reports a visible error instead of failing silently if it can’t read the open message. -
Redesigned the Thunderbird extension’s popup and options page with a Mercury-themed look (a winged caduceus mark, a silver/sky-blue/gold palette) instead of unstyled default form controls.
-
/rules/proposeno longer commits a rule immediately. It now proposes a rule (and, if the instruction also calls for an action on mail that already exists, a separately scoped action) over Telegram and waits for a reply: “yes” commits the rule and carries out any action via the judge provider’s own mailbox-action skill, “no” discards it, anything else is treated as feedback and the proposal is revised and re-sent (capped at a few rounds). Newbackend/approvals.py(persisted pending proposals) andbackend/telegram_approvals.py(the reply loop itself, independent of whichever Notifier is configured). -
Fixed “NetworkError when attempting to fetch resource” on send: the popup’s cross-origin POST to the configured Mercury URL was being blocked by CORS, since Firefox/Thunderbird extension pages need an explicit host permission to bypass it. Added
host_permissions: ["*://*/*"]- can’t be scoped narrower since the Mercury URL is user-configured rather than fixed at build time. -
Fixed the popup always reporting “No message is currently displayed” even with a message open. Two mistakes at once, both confirmed against Thunderbird’s own official messageDisplay example:
getDisplayedMessagesneeds an explicit tab id (querying with no tabId does not reliably find the displayed message from inside this popup, contrary to my previous fix’s assumption), and it resolves to aMessageListobject ({messages: [...], ...}), not a bare array - the code was checking.lengthon the wrapper object, which is alwaysundefined. -
The Thunderbird extension now has a real release process: a
thunderbird-vX.Y.Ztag triggers.github/workflows/release-thunderbird.yml, which builds the.xpi, attaches it to a GitHub Release, and pointsthunderbird/updates.jsonat it. The extension’smanifest.jsonnow declaresbrowser_specific_settings.gecko.update_urlpointing at that file, so an installed (non-temporary) copy checks for and applies new versions on its own - no more manual reinstall per release, as long as unsigned installs are still allowed for the profile. Bumped to 0.2.0. -
Flagging now supports selecting multiple messages at once (Thunderbird’s
getDisplayedMessagesalready returned an array - the popup only used to send the first one). All selected messages go into the same proposal, labeledMessage N of M, capped at 10 messages / 2000 chars each./rules/propose’s payload shape changed from a singlemessageobject to amessagesarray; no backward-compat kept since both ends ship together. -
Fixed the popup failing with “getDisplayedMessage is not a function”: the real Thunderbird API is
messageDisplay.getDisplayedMessages(plural, returns an array) - there is no singulargetDisplayedMessage. My earlier fix for the dead submit button had guessed the wrong method name. -
gateway/README.mdnow documents running the gateway under a real process supervisor (systemd/launchd/pm2/etc.) instead of a barenohup ... &, and fetchingAGENT_GATEWAY_SECRETat process start rather than embedding it in a supervisor unit file. The reference deployment now does this via alaunchdjob on macOS. -
Documented that a VM-based Docker runtime (Colima, Lima, etc.) needs its own periodic health check independent of the container’s own restart policy, since
restart: unless-stoppeddoesn’t help if the VM underneath it wedges. The reference deployment now runs a watchdog that checks Docker responsiveness every 5 minutes and escalates through restart strategies if not, also verifying the Mercury container itself comes back up. -
Added a
message_listright-click context menu entry (“Flag for Mercury”) to the Thunderbird extension, alongside the existing toolbar button - it opens the same popup viamessageDisplayAction.openPopup(), so flagging no longer requires opening the message first. -
A thumbs-up reaction on a proposal message in Telegram now approves it, same as replying “yes”.
-
Added an unsubscribe action, alongside the existing mailbox action, for instructions that ask to be unsubscribed from a sender. Its safety is evaluated first (a link with no clear relationship to the sender’s own domain, or a page asking for credentials, is treated as unsafe and never visited); if safe, tracking query parameters are stripped and the unsubscribe is completed, then the sending domain is set to soft-bounce; if unsafe, unsubscribing is skipped and the domain is set to hard-bounce instead. See “Executing an approved unsubscribe” in
docs/ARCHITECTURE.md. -
Enforcement is now live. The backend’s
/ingestresponse status is the judge’s actual disposition (250/421/550) rather than always 200, and the Worker gate now waits for it and returns it to the mail host - but fails open (accept) on anything short of a clean, recognized disposition from the backend (unreachable, slow past a fixed timeout, or an unrecognized status), so infrastructure trouble can never itself cause a bounce.MERCURY_SHADOW_MODE=truereverts to report-only without a code change or redeploy. -
Fixed the “Flag for Mercury” context-menu entry never appearing: it was only registered inside
runtime.onInstalled, which Thunderbird’s own officialquickfilterexample warns against for exactly this reason -menus.create()now runs unconditionally at background-script top level (guarded against the resulting “already exists” rejection on a normal restart), matching the pattern Thunderbird’s own examples use. -
Replaced thumbs-up-reaction approval with inline keyboard buttons (Approve/Discard) on each proposal message. Telegram’s Bot API only delivers
message_reactionupdates when the bot is an administrator in the chat - a role that cannot exist in a private one-on-one chat, so a reaction there was silently never received. Plain “yes”/“no” text replies still work as before; anything else is still read as revision feedback. -
Unsubscribe no longer adds a bounce rule automatically. It reports its own outcome back to Telegram first (
UNSUBSCRIBED/FAILED/SKIPPED_UNSAFE, plus a summary), then separately asks - as its own inline-button question - whether to add the sending domain to the blacklist (hard bounce), the greylist (soft bounce), or leave it alone. An unsubscribe request is not itself a request for a standing rule. -
The verdict step now also assigns a category (from a fixed label set: newsletter, promotional, transactional, account security, personal, social, financial, phishing, scam, malware, other) and its own alert level (none / standard / urgent) - a judgment call on whether the recipient should be pinged in Telegram right now versus having it show up in the daily summary and dashboard instead. Only standard and urgent alerts reach Telegram individually now; routine traffic, including most hard bounces, no longer pings on every message.
-
Added a structured event log backing a forthcoming dashboard and daily summary: every verdict, rule change, and mailbox/unsubscribe action is now recorded to a Cloudflare D1 database (
worker/schema.sql), written through a new authenticated/logroute on the Worker gate rather than giving the backend its own Cloudflare credentials (backend/event_log.py). A hard-bounce recommendation also saves the full message and reasoning, so it can be reviewed later without having had to catch it live. Logging is fire-and-forget and never affects delivery if it fails. -
Approving an action (mailbox or unsubscribe) now sends an immediate “working on it” message before the agent call, which can take a while - the button-tap toast alone was too easy to miss, leaving it unclear whether a tap or reply had actually registered. The agent is also now asked to post its own brief progress updates as it works (e.g. “Examining the unsubscribe link…”) rather than going quiet until the final report.
-
Removed the parenthetical disposition explanation from the Blacklist/Greylist/No bounce button labels themselves (it stays in the question text above them).
-
The rule-proposal interpreter can now be told an instruction arrived via speech-to-text dictation (a
via_dictationflag, plumbed through from/rules/proposebut not yet set by any real input method) - it’s asked to read past likely transcription errors and infer intent rather than take garbled phrasing literally, still deferring to the normal revise-feedback loop when genuinely unclear. -
Gave the extension a real icon (a winged-helmet shield mark) instead of Thunderbird’s default puzzle-piece placeholder - used in
icons/, the toolbar button, and the context-menu entry. The source image had a checkerboard baked into its pixels as fake transparency rather than a real alpha channel; rebuilt with a genuine one by flood-filling only the background region connected to the image border (so similarly-toned pixels inside the artwork itself are left alone), then cropped and padded to a square at the sizes Thunderbird actually uses (16/32/48/64/96/128). -
Added the categorized taxonomy’s two missing common cases - shipping/delivery notices and political/fundraising asks - as their own labels rather than folding into transactional/promotional.
-
Added a first dashboard at
/dashboardon the Worker’s own domain: 24h/7d summary cards, a category breakdown, a filterable (accepted/deferred/ bounced) recent-activity table with color-coded rows, and recent rule changes and actions - all reading live from the D1 event log (worker/src/dashboard.js, new/dashboard/api/*routes inworker/src/index.js). Gated by HTTP Basic Auth against aDASHBOARD_PASSWORDWorker secret - the browser prompts once and remembers it; Cloudflare Access (restricting by the actual owner email) is a stronger option to layer on later if wanted, but needs Zero Trust account configuration this Worker can’t set up on its own. -
Added trend charts (message volume by disposition, category volume) over the last 30 days to the dashboard, rendered as inline SVG built from D1 query results on the Worker itself - no client-side charting library or canvas (
renderStackedBarSVGinworker/src/dashboard.js). -
Added a hard-bounce detail view: an aggregate list of every hard-bounced message, expandable per row to the full saved message text and the judge’s full reasoning, plus - when a specific standing rule decided the disposition - that rule’s text and a control to reverse it. The judge now reports which rule (if any) applied, verbatim, alongside its verdict (
RULE_MATCHinbackend/app.py’s prompt), stored in a newmessages.triggered_rulecolumn. Reversal calls a new backend endpoint,POST /rules/reverse, authenticated the same way the backend’s own calls into the Worker’s/logroute are (X-Mercury-Secretagainst the shared secret both sides hold) - the rules ledger lives only on the backend’s filesystem, not in D1, so the Worker cannot remove a rule from it directly. -
Added an action-item checklist to the dashboard: open
action_itemsrows with a checkbox that marks one complete (POST /dashboard/api/action-items/{id}/complete, setting the existingcompleted_atcolumn rather than adding a redundant boolean one). -
Both the rule reversal and action-item completion are recorded to the
admin_logtable. -
Fixed the dashboard loading over plain HTTP with a “Not Secure” warning: the Worker now redirects any
http://request tohttps://itself (301) rather than relying on a zone-level setting, and dashboard responses sendStrict-Transport-Securityso the browser remembers to use HTTPS for this domain going forward. -
Fixed real mail never reaching the pipeline at all: the ForwardEmail catch-all and test aliases were configured against
/webhookand/webhook-test, but the Worker and backend only ever implemented/ingest- every real incoming message had been 404ing since the alias was created. Confirmed by checking the backend’s own request log rather than assuming the configured URL matched the code. Fixed by treating/webhookand/webhook-testidentically to/ingest. -
Discovered (via the ForwardEmail API, checking the actual alias configuration rather than assuming) that the catch-all alias - and five other aliases - deliver directly to
aaron@rpgm.tools’s real mailbox in parallel with, or instead of, the Mercury webhook. Since ForwardEmail delivers to multiple recipients on one alias independently with no cross-talk, this meant Mercury’s disposition had never actually been able to block anything from reaching the inbox. -
Added the other half of enforcement: an accepted (250) message is now delivered into the real mailbox by Mercury itself via IMAP APPEND (
backend/mail_delivery.py), tagged withX-Mercury-Verdict,X-Mercury-Category, andX-Mercury-Dispositionheaders, using the original raw message ForwardEmail’s webhook payload includes rather than a reconstruction. Soft-deferred and hard-bounced messages are simply never appended - that’s what makes enforcement actually binding, instead of advisory alongside an independent parallel delivery. Gated byMERCURY_DELIVER_ACCEPTED_MAIL(default off) since flipping it on must happen together with removing the mailbox’s own address from each affected alias’s recipient list, never one without the other. -
Validated the IMAP delivery path end to end (a test message landed in the real mailbox tagged with the right headers), then cut over all six affected ForwardEmail aliases (the catch-all,
dallenb4, theaaron*/strider*/aragorn*/neostryder*regex alias,verification,/^dallen(.*)$/, and/^finance(.*)$/) to route through Mercury instead of delivering toaaron@rpgm.toolsdirectly, preserving any other co-recipient on the same alias (e.g.dallenb4@rpgm.toolsstays on the/^dallen(.*)$/alias, unfiltered, exactly as before). Enforcement is now actually binding for the first time - every path that reaches the real inbox goes through Mercury’s classifier and rules ledger first. -
Fixed the backend Dockerfile’s explicit per-file COPY list (the same pattern that caused a real outage earlier this session) by copying every
.pyfile in the directory instead, so a future new module can’t be left out of the image by omission again. -
The Thunderbird extension now applies a color-coded native tag to every new message, one per judge category, based on its
X-Mercury-Categoryheader.messenger.messages.tags.create()runs once per category at background-script startup, checked againsttags.list()first so it stays idempotent across restarts;onNewMailReceivedthen reads each new message’s full headers, matches the category to its tag, and applies it viamessenger.messages.update(), merging with any tags already on the message rather than replacing them. Added theaccountsRead,messagesUpdate,messagesTags, andmessagesTagsListpermissions this requires. Bumped to 0.3.3. -
Added a daily digest email (
backend/digest.py): once a day at 7am America/Phoenix time (a fixed UTC-7 offset - Arizona does not observe DST), the backend gathers the last 24 hours of activity from the Worker’s existing/dashboard/api/*routes over authenticated HTTPS, and sends a standalone HTML email (inline CSS only) toaaron@rpgm.toolscovering message volume, verdict and category breakdowns, a ledger of rule changes/actions/hard bounces, the current standard/urgent alert list, and a short insights paragraph from the judge provider. Sent fromgandalf@rpgm.toolsvia ForwardEmail’s SMTP. Runs as a plainasynciobackground task inside the existing FastAPI service - no new dependency and no separate cron container. Gated byMERCURY_DASHBOARD_USER/MERCURY_DASHBOARD_PASSWORD(the same Basic Auth the browser dashboard already uses) andMERCURY_DIGEST_SMTP_USER/MERCURY_DIGEST_SMTP_PASSWORD; any missing, and it logs a skip reason at startup instead of running. -
Fixed the context menu entry and icon vanishing after the category-chip update:
ensureCategoryTagsExist()ran unguarded at background-script top level, after the context menu and its click listener were already registered - an uncaught rejection there could take the whole background page down with it. Wrapped it and the per-message tagging call in their own try/catch, logging instead of throwing, so a tagging failure can’t affect the menu, popup, or icon. Bumped to 0.3.4. -
Fixed the release workflow’s
.xpinever containingbackground.jsoricons/- onlymanifest.json, the popup, and the options page were ever zipped, even though the manifest declares both as required resources. Every installed release built by this pipeline has been missing its own background script and icons since the workflow was created; a temporary/unpacked load reads the whole folder directly and was never affected, which is why this went unnoticed. -
Fixed a silent failure in the Telegram approval loop’s final report:
_send()swallowed every exception with no logging, and never truncated its outgoing text - a mailbox action’s own report of what it did (e.g. every message it deleted) can easily exceed Telegram’s ~4096 character limit, and Telegram rejects an oversized message outright. Now the text is truncated before sending, a send failure is logged toadmin_loginstead of disappearing, and a short fallback notice (“check the dashboard’s Recent Actions”) is sent so the recipient is never left in total silence about whether an approved action actually ran. -
Fixed the dashboard’s trend charts bucketing messages by UTC calendar date instead of Phoenix time: a message received in the evening (Phoenix is a fixed UTC-7 offset) already falls on the next UTC day, so it showed up under tomorrow’s date. Both the D1 query’s
date(received_at)and the chart’s day-axis generation now shift by the same fixed 7 hours before taking the calendar date. -
Fixed flagged-message requests always getting framed as a rule proposal, even a purely one-time mailbox action with no standing preference intended.
interpret_instruction()’s prompt treated rule extraction as the primary task and action detection as an afterthought; it now asks whether the instruction expresses a standing preference and/or an action on existing mail as two independent, equally optional questions. The Telegram proposal message now reads “rule proposed”, “action proposed”, or “rule + action proposed” depending on what’s actually there, instead of always showing a rule line (previously “Rule: None” when there wasn’t one). -
Added a caveat check to rule proposals:
interpret_instruction()andrevise_instruction()now also judge whether a proposed rule actually adds distinguishing criteria beyond the baseline verdict step’s own SPAM/PHISH/LEGIT/UNSURE reasoning, or whether it just restates “obviously bad mail should be blocked” and would therefore never be the deciding factor. The rules ledger is semantic by design - a broad rule is meant to be supported - but the recipient now sees a direct heads-up before approving one that’s unlikely to ever do anything, instead of finding out after the fact. -
Replaced the single-shot rule/action parse with an open-ended brief.
interpret_instruction()/revise_instruction()are gone;advance_brief()decides among QUESTION/RULE/ACTION/CAVEAT each turn against the brief’s full history, so an unclear request gets asked about over Telegram instead of forced into a rule. Every message Mercury sends for a brief - not just its first proposal - is tracked back to it, so any reply continues the same conversation rather than being silently dropped; a resolved brief stays reachable too, and a later challenge to its outcome gets a real answer fromdiscuss_resolved_brief()rather than nothing.backend/approvals.py’s store now holds full turn history and a message-id-to-brief index rather than one flat proposal dict. -
Fixed per-message verdict reports (the “Mercury report” / URGENT alerts sent for a STANDARD or URGENT verdict) being unreplyable - they went out through the one-way
Notifierinterface, which never captured a message id, so a reply asking “why was this UNSURE” or “whitelist this sender” had nothing to route back to. They now go out throughsend_trackable_report(), opening a brief the same way a proposal does, so they’re a normal part of the same conversation. Pipeline-failure alerts are unaffected - those aren’t about a specific email and stay on the plain one-wayNotifierpath. -
Visible Fixed the Thunderbird flag popup showing a literal “Proposed: null” when a flagging instruction produced only an action (for example an unsubscribe-plus-hard-bounce request) with no standing rule attached. The status line now omits the rule line entirely when there is none, and reads “Proposed:
” instead of “Also proposed: ” in that case. -
Visible Fixed the daily digest email showing raw ISO timestamps (with microseconds and a UTC offset) in the “Needs a look” and ledger tables, and let a single verbose piece of judge reasoning stretch a table row across many lines. Timestamps now render as short Phoenix-local times, and the reasoning, subject, and sender columns are length-capped; full detail is still available on the dashboard.
-
Visible Fixed a brief proposing an action that could never actually happen: asked to “un-defer” mail that had been given a 421 disposition, Mercury proposed a
MAILBOXaction against a “Deferred mail” folder that doesn’t exist, because a 421 or 550 disposition rejects the message at SMTP time and it was never delivered anywhere - there is no folder to act on.advance_brief()(backend/app.py) is now grounded with the mailbox’s real IMAP folder list (mail_delivery.list_folders()) and with the fact that a 421/550 disposition means the message was never stored, so it never invents a folder and knows to explain viaCAVEATinstead of proposing an action that can’t do anything.CAVEATitself is broadened to cover this case (not just a rule that doesn’t add anything), and a caveat with no rule or action attached now reaches Telegram as its own message instead of being silently replaced with “nothing to add or do.” Also strengthened the brief prompt to decompose a multi-part request into whatever combination of a rule and an action actually satisfies it, rather than transcribing it close to verbatim. -
Internal Replaced the flat semantic rules ledger with one versioned, atomically written filtering policy containing deterministic blacklist, greylist, and whitelist sender entries; 550, 421, and 250 semantic rule buckets; and standing custom actions. Exact sender addresses override domains, and adding a selector to one deterministic list removes it from the other two. Legacy policy migrates in place with the known sender and semantic entries, omitting the superseded PayPal/GOG exception and Nellis Auction test.
-
Visible Deterministic sender matches now provide the final disposition before content processing and skip both the injection classifier and semantic judge. Their message events contain an explicit skipped-list injection label, verdict, category, and list-match reasoning. Whitelist matches also skip content scanning by design. Accepted deterministic matches use a
SENDER_LISTThunderbird category tag. -
Visible Split the semantic judge context into labeled 550, 421, and 250 rule blocks, retaining exact
RULE_MATCHreversal. Recalibrated the general prompt toward LEGIT/250 for ordinary transactional mail and identifiable business newsletters, reserving 421 for concrete ambiguity and 550 for clearly malicious, lewd, or unsolicited spam content. -
Visible Added Unsubscribe, Soft-bounce, Hard-bounce, and Deliver + whitelist buttons to STANDARD and URGENT Telegram reports. The one-message action executes first; any matching sender-list entry is presented afterward as a separate Approve/Discard proposal. A retained raw message supports manual delivery after defer and is removed from approval state when the decision runs or the brief resolves.
-
Visible Added standing custom actions. Native folder routing changes the IMAP APPEND target for accepted mail; non-native instructions run through the scoped mailbox-action agent after successful delivery. The migrated list starts empty.
-
Visible Added authenticated dashboard management for all deterministic sender lists, semantic rule buckets, and standing custom actions, including visible warnings for any unrecognized text encountered during legacy migration.
-
Visible Deterministic blacklist, greylist, and whitelist matches now require ForwardEmail’s own
dmarcwebhook verdict to report a pass for the claimedFrom:domain. A missing, failed, or malformed verdict sends the message through the normal classifier and semantic judge instead, and the message event records why the unauthenticated match was skipped. A domain with no published DMARC policy never takes the deterministic path in either direction - this was deliberately changed from an initial approach that re-parsed the raw message’s ownAuthentication-Resultsheaders, since those can carry a forged assertion claiming to be from a server other than ForwardEmail itself (ForwardEmail only strips forgeries of its own identity), which would have reopened the exact spoofing gap this check exists to close. -
Visible Fixed a resolved brief being a dead end: any reply after a brief settled - even one asking whether an earlier request actually got carried out - used to get a purely conversational answer that could never itself change anything, no matter how explicit the recipient’s follow-up was. A brief no longer distinguishes “still open” from “already resolved” for the purpose of what a reply can do - every reply is re-read against the full conversation, and a corrected understanding now proposes the actual change or action right then rather than requiring the message be re-flagged from scratch. A new
REPLYfield lets a turn answer a direct question (e.g. “no, that was never done”) without having to invent a filtering change or action just to say something. Also removed the fixed round limit that used to auto-discard a brief that had gone back and forth for a while - an unresolved brief now stays reachable for as long as it takes, rather than getting silently abandoned mid-conversation. -
Internal Fixed
_parse_brief_response’s field regexes matching a field name anywhere in the text rather than only at the start of a line, which meantACTION:could match insideCUSTOM_ACTION:and silently return the wrong value (or the custom action’s own text) whenever both fields were present in the same response. Field patterns are now anchored to an actual line start. -
Visible Approved unsubscribes can now pause with
NEEDS_SIGNINwhen a verified sender-related preferences page requires an account login. Mercury posts a 10-minute, single-use form link into the same Telegram brief so the credential is entered on Mercury’s own TLS-protected domain, never in bot chat. Unanswered prompts end without a bounce suggestion, and any MFA or 2FA challenge stops with a clear manual-completion result. -
Internal Added a memory-only, single-use credential prompt store, public token-authorized backend GET/POST endpoints with 4 KB request limits, and matching Cloudflare Worker form routes with HSTS and no-store headers. The unsubscribe executor supplies the submitted value to one scoped judge retry, immediately removes it from pending memory, and prevents it from reaching event logs or persisted brief history.
-
Visible The dashboard’s messages, hard-bounces, rule-change, and actions tables now have pagination: a 20/50/100 page-size selector plus Prev/Next, instead of a single fixed-size page with no way to see older rows.
-
Visible The event log now purges automatically. A daily Worker cron deletes messages, actions, rule-change, and admin-log rows older than a configurable window (
LOG_RETENTION_DAYS, defaulting to 365 days), and completed action items past the same window. An open action item is never purged by age alone. -
Visible Added a
GANDALFbrief action for handing a flagged message to Gandalf/Loremaster for separate competitor, canon, research, or planning work. Once approved, Mercury emails the summarized instruction and flagged context togandalf@rpgm.toolsand reports whether the handoff was sent. -
Internal Added
backend/gandalf_relay.py, an SMTP-over-SSL relay that reuses the mailbox’s existing IMAP username and password. Its SMTP endpoint is configured byMERCURY_MAILBOX_SMTP_HOST(defaultsmtp.forwardemail.net) andMERCURY_MAILBOX_SMTP_PORT(default465).