Give email TLS it cannot lose, and a subject it cannot vary - #32
Conversation
Issue #2: email is the first delivery rail, chosen for reach, and also the weakest privacy class a channel can have -- PROVIDER_READS -- so the two things worth getting right here are not features, they're what the channel refuses to do. The connection is wrapped in TLS from the first byte (SMTP_SSL, port 465) so there is no STARTTLS negotiation for a network position to strip. That is the whole answer to "enforce TLS, no opportunistic downgrade": the downgrade has nothing to attach to because there was never a plaintext phase to begin with. The subject line is a module constant, never built from the alert, because a subject is logged and indexed at every hop between here and the recipient's inbox -- one that varied with alert kind would be a side channel of its own. The body templates carry only the label, chain and status per invariant I2, which the dataclass already makes structurally true; a test renders every AlertKind and both Directions and greps for anything address-, amount- or txid-shaped anyway, because the templates are free text and a constant is only proof about the schema, not the prose. Every SMTP failure is classified without ever writing the destination into DeliveryResult.detail -- not even the address inside the exception smtplib hands back for SMTPRecipientsRefused. 4xx is retriable, 5xx and a refused recipient are not, mirroring ordinary SMTP semantics rather than inventing new ones. Tests use an in-process fake of the connected client (fake_smtp.py), the same choice fake_node.py makes for the RPC supervisor: smtplib's own transport is already tested, so faking the socket underneath it would mostly re-prove that. What's worth checking is this module's own logic, and a live TLS handshake would need a certificate to fake without proving anything about the code. The one thing that must be checked at the transport boundary -- that the default path really is SMTP_SSL, not STARTTLS -- gets its own test via a monkeypatched smtplib.SMTP_SSL that records how it was called. Four mutations added to tools/mutate.py, each tied to one of the above: a varying subject, a 5xx treated as retriable, the destination leaking into detail, and validate_dest skipping case normalisation. All caught; the sweep in full is still 71/71 as expected. `from_env` mirrors node.rpc.BitcoinRpc.from_env exactly -- systemd LoadCredential, then a password file, then the bare variable, in that order -- because there was no reason to invent a second convention for the same problem. `privacy_ack` itself is out of scope: it's an enrolment-time gate (#23, not started), and this module's job is only to expose privacy_class honestly so that gate has something true to check. Refs #2 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wired4ncer
left a comment
There was a problem hiding this comment.
Checked out and ran locally: 235 tests pass, ruff clean, and the sweep is green at 71/71 as you said. The privacy posture holds up under prodding — implicit TLS really has nothing to strip, the subject constant survives all six alert kinds, and the detail-leak tests are worth something because the exception payloads you feed them genuinely contain the address, so those assertions can fail. The four new mutations each tie to a specific claim in the module rather than to whatever line happened to be convenient, which is the useful kind.
Three defects, one of them in the path this whole project exists for. Details inline.
One thing that isn't in the diff: .github/workflows/ci.yml:31 still says the sweep breaks the code "67 ways". It's 71 now.
On privacy_ack being out of scope — agreed, and exposing privacy_class honestly is the right amount of surface for #23 to build against.
…AUTH Wired4ncer's review on #32 found three real defects, one of them in the exact path this project exists for. A MOVEMENT alert with direction=None fell through to the calm deposit template rather than the alarm. Unreachable today -- the matcher always sets a direction -- but Alert.direction is typed Direction | None and nothing in this module enforces the invariant that a MOVEMENT carries one; the layer that actually builds Alert doesn't exist yet (#23). Inverted the branch so the alarm is the fallback and the deposit notice is the one that requires an explicit INCOMING, since guessing wrong here is unrecoverable for the user in a way guessing wrong the other direction is not. A malformed SMTP reply comes back from smtplib as smtp_code == -1, which `400 <= code < 500` classifies as permanent and drops on the floor -- a garbled response is a transport problem, not a verdict on the message. The guard is now `code < 500`: 5xx is the only thing that means "don't retry." SMTPNotSupportedError -- raised when the server offers none of the AUTH mechanisms login() knows -- isn't an SMTPResponseException, so it fell into the generic transient bucket and would have retried forever against a server that can never satisfy the request. Caught explicitly now, alongside SMTPRecipientsRefused, as the permanent configuration error it is. Also: documented next to COLDWATCH_SMTP_PORT that this channel only ever speaks implicit TLS, since pointing it at 587 -- the port most providers' own docs name -- currently hangs to timeout with no clearer diagnostic than that. And ci.yml's comment said the sweep breaks the code "67 ways"; it's 71 before this commit, 74 after. Three mutations added for the three defects (direction fallback, the -1 off-by-one, no-AUTH-as-permanent), each tied to the specific line the review pointed at rather than to whatever was convenient. Full sweep: 74/74 as expected, 3 known equivalent -- same three as before, none of this touched them. Refs #2 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sweep is 74 mutations; the comment said 71. It said 67 before this branch, and the commit that corrected it to 71 added three more in the same breath -- so the number has now been wrong at every point it has been written down. Fixing the digit is the smaller half. A count kept in prose, three files away from the list it counts, has no way to notice when the list grows; it is accurate only for as long as nobody adds a mutation, which is the one thing this file exists to encourage. Either the comment stops carrying a number or mutate.py asserts it, and that is a change to make on its own rather than inside a review of the email channel. Refs #2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wired4ncer
left a comment
There was a problem hiding this comment.
All three defects are fixed, and fixed at the right level rather than patched at the symptom.
The direction fix is the one I care most about, and inverting the branch was the better of the two shapes available: the alarm is now structurally the fallback, so a future AlertKind that reaches that block without a direction also lands on the alarm rather than needing someone to remember this conversation. Both templates are byte-identical to before, so FORBIDDEN_IN_BODY still means what it meant.
The new mutations are the good kind. is INCOMING -> is not OUTGOING diverges from correct behaviour only when the direction is None, so nothing can catch it except a test that exercises the actual hole — which is the difference between a mutation that proves a test and one that just fails.
Verified locally rather than reading the diff: 238 tests, ruff clean, sweep 74/74 with each new mutation caught by exactly the test that should catch it, tree clean afterward. Behaviour spot-checked at the boundary — MOVEMENT/None now renders "has moved", smtp -1 comes back retriable, SMTPNotSupportedError comes back permanent.
Documenting the 587 trap on ENV_PORT rather than rejecting the port was the right call for v1 — it puts the warning where someone setting the variable will actually read it.
I pushed one commit (44c9a79) for the mutation count in ci.yml: it read 71, and the sweep is 74, because the same commit that corrected 67 -> 71 added three more. Digit fixed; the underlying problem is that the number lives in prose three files from the list it counts, and that wants its own change rather than another round-trip here.
Nothing blocking. Thanks for #22 staying separate — postfix hardening genuinely does belong with the rail rather than inside it.
Two things the docs have been getting wrong. CONTRIBUTING said the runtime has exactly one third-party dependency. That stopped being true on 2026-08-12, when #3 accepted coincurve and websocket-client; SECURITY.md was amended then and this was not. It now states three, points at the table that says what each is trusted with, and carries the rule #3 actually settled -- which is not a count but a shape: impossible or dangerous in Python, outside the alert path, and no published vectors we could check ourselves. The README's status table said enrolment, payment and delivery were all 'not started'. Delivery has been built since #32 and #35, and enrolment now has its storage layer. Split into four rows, each saying what is *not* proven -- neither channel has yet delivered an alert raised by the engine, because nothing can enrol. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #2: email is the first delivery rail, chosen for reach, and also the weakest privacy class a channel can have -- PROVIDER_READS -- so the two things worth getting right here are not features, they're what the channel refuses to do.
The connection is wrapped in TLS from the first byte (SMTP_SSL, port 465) so there is no STARTTLS negotiation for a network position to strip. That is the whole answer to "enforce TLS, no opportunistic downgrade": the downgrade has nothing to attach to because there was never a plaintext phase to begin with.
The subject line is a module constant, never built from the alert, because a subject is logged and indexed at every hop between here and the recipient's inbox -- one that varied with alert kind would be a side channel of its own. The body templates carry only the label, chain and status per invariant I2, which the dataclass already makes structurally true; a test renders every AlertKind and both Directions and greps for anything address-, amount- or txid-shaped anyway, because the templates are free text and a constant is only proof about the schema, not the prose.
Every SMTP failure is classified without ever writing the destination into DeliveryResult.detail -- not even the address inside the exception smtplib hands back for SMTPRecipientsRefused. 4xx is retriable, 5xx and a refused recipient are not, mirroring ordinary SMTP semantics rather than inventing new ones.
Tests use an in-process fake of the connected client (fake_smtp.py), the same choice fake_node.py makes for the RPC supervisor: smtplib's own transport is already tested, so faking the socket underneath it would mostly re-prove that. What's worth checking is this module's own logic, and a live TLS handshake would need a certificate to fake without proving anything about the code. The one thing that must be checked at the transport boundary -- that the default path really is SMTP_SSL, not STARTTLS -- gets its own test via a monkeypatched smtplib.SMTP_SSL that records how it was called.
Four mutations added to tools/mutate.py, each tied to one of the above: a varying subject, a 5xx treated as retriable, the destination leaking into detail, and validate_dest skipping case normalisation. All caught; the sweep in full is still 71/71 as expected.
from_envmirrors node.rpc.BitcoinRpc.from_env exactly -- systemd LoadCredential, then a password file, then the bare variable, in that order -- because there was no reason to invent a second convention for the same problem.privacy_ackitself is out of scope: it's an enrolment-time gate (#23, not started), and this module's job is only to expose privacy_class honestly so that gate has something true to check.Refs #2