Skip to content

Give email TLS it cannot lose, and a subject it cannot vary - #32

Merged
magicka7 merged 3 commits into
mainfrom
feat/email-channel
Aug 10, 2026
Merged

magicka7 merged 3 commits into
mainfrom
feat/email-channel

Conversation

@magicka7

@magicka7 magicka7 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

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

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>
@magicka7
magicka7 requested a review from Wired4ncer as a code owner August 9, 2026 23:01

@Wired4ncer Wired4ncer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/coldwatch/channels/email.py
Comment thread src/coldwatch/channels/email.py Outdated
Comment thread src/coldwatch/channels/email.py
Comment thread src/coldwatch/channels/email.py
Comment thread src/coldwatch/channels/email.py
magicka7 and others added 2 commits August 9, 2026 18:26
…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 Wired4ncer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@magicka7
magicka7 merged commit 528ab9c into main Aug 10, 2026
4 checks passed
Wired4ncer added a commit that referenced this pull request Sep 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants