Skip to content

Email channel: implement EmailChannel against the Channel protocol #2

Closed

Description

@Wired4ncer

Implement EmailChannel — the first delivery rail, chosen because email has universal reach.

Self-contained: needs no node, credentials, or host access. The Channel protocol
(docs/architecture.md §5) is the whole interface.

Scope

  • validate_dest(raw) — normalise and validate an address; raise on junk.
  • send(dest, alert) — deliver one Alert, return a DeliveryResult.
  • Templates per AlertKind: outgoing is an alarm, incoming is calm and informational.
  • Unit tests against a local SMTP sink. No real mail server required.

Constraints (these are not negotiable — see CONTRIBUTING.md §1)

  • The subject line is a constant. Subjects are logged and indexed at every hop. It must not
    vary with the alert, because variation is itself a signal.
  • The body carries the nickname, chain and status. Nothing else. No address, no amount, no
    txid. The Alert dataclass is frozen and deliberately lacks those fields — if you find
    yourself wanting one, that is invariant I2 doing its job. Open a discussion, don't add a field.
  • TLS enforced. No opportunistic downgrade.
  • privacy_ack is required before an email channel can be used: email is PROVIDER_READS, the
    weakest privacy class, and the user must be told so in plain words.
  • DeliveryResult.detail must never contain the destination or the alert content.

Out of scope

Postfix hardening and the mail log/queue purge — separate issue, on me, since it is my
infrastructure.

Definition of done

Tests cover a successful send, a retriable failure, a permanent failure, and a proof that no
template can emit an address, amount or txid.

Activity

  1. added
    area:channelsAlert delivery: email, Nostr, webhook, ntfy
    good-first-taskSelf-contained; no node or host access needed
    privacy-criticalTouches an invariant — needs a second reader
    on Aug 6, 2026
  2. Wired4ncer commented on Aug 9, 2026

    @Wired4ncer
    OwnerAuthor

    ⚠️ This is not shippable on its own — see #22.

    dest_ct encrypts the destination at rest, and postfix then writes it in plaintext to two places that encryption does not reach: the mail log and the deferred queue. So an EmailChannel that works perfectly still leaves a plaintext list of who is watching, on the same box, in files nothing in this design accounts for.

    #22 has to land in the same slice rather than as a follow-up, because every alert sent in between is in the log permanently. Nothing about the Python here changes — this comment exists so that "the channel is done" and "the channel is safe to turn on" do not get confused for each other.

    The related half already in scope here: the privacy_ack text has to say that email egress is CLEARNET. Tor exits are SMTP-blocklisted, so a smarthost-over-Tor was considered and rejected — our egress hides us, never the user.

  3. Wired4ncer commented on Aug 10, 2026

    @Wired4ncer
    OwnerAuthor

    #32 is merged (528ab9c) and it delivers everything this issue asked of the module. Keeping #2 open anyway, and I want to be explicit about why, because an open issue under a merged PR reads like an unfinished job and this is not one.

    Done, and done to the definition of done written above: validate_dest, send, a template per AlertKind, and tests covering a successful send, a retriable failure, a permanent failure, and the proof that no template can emit an address, amount or txid. The three constraints that were called non-negotiable all hold, and hold structurally rather than by convention — the subject is a module constant, the body can only reach label/chain because Alert carries nothing else, and TLS is wrapped from the first byte so there is no STARTTLS phase for a downgrade to attach to. The sweep is at 74 and every email mutation is caught by a test that exercises the actual hole.

    What is left is not in the module, and not yours:

    1. privacy_ack — listed as a constraint here, but it is an enrolment-time gate and there is no enrolment API yet. It belongs to Enrolment API, capability tokens, and the ARMING state machine #23. EmailChannel does its half already by declaring PROVIDER_READS honestly, so Enrolment API, capability tokens, and the ARMING state machine #23 has something true to check rather than a promise to trust.
    2. Postfix hardening + log/queue purge ships WITH the email channel, not after it #22, postfix hardening — already marked out of scope above as mine, since it is my infrastructure.

    One thing that changed shape, worth recording here rather than discovering later. #22 is worded as blocking #2 — "ships in the same slice as EmailChannel, or the channel's privacy guarantee is not real." That framing has been overtaken: the channel is in main and postfix is untouched, and the guarantee is nonetheless intact, because nothing can call the channel. There is no enrolment API, so no destination has ever reached postfix.

    That makes the ordering constraint #22 before #23, not #22 before #2. The moment enrolment wires a real destination to this module, the first alert writes a recipient address into the mail log permanently — which is exactly what #22 exists to prevent, and #23 is the next thing in the queue. So the dependency is live now rather than hypothetical, and I am moving it onto myself before enrolment starts rather than after.

    Nothing in here is waiting on you. #3 is your lane and it is unblocked.

  4. Wired4ncer commented on Aug 13, 2026

    @Wired4ncer
    OwnerAuthor

    Closing: shipped in #32 and live in channels/email.py.

    The definition of done in this issue was a successful send, a retriable failure, a permanent failure, and a proof that no template can emit an address, amount or txid — all four are covered in tests/test_email_channel.py, and the last one is a test that renders every AlertKind and both Directions and greps the result. Implicit TLS from the first byte (SMTP_SSL, 465), a constant subject, and a DeliveryResult.detail that never carries the destination are all in place, with 7 mutations in the sweep holding them there.

    Postfix hardening and the mail log/queue purge are out of scope here by this issue's own wording and stay open as #22.

    This has been done since 2026-08-09 and the issue was simply never closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:channelsAlert delivery: email, Nostr, webhook, ntfygood-first-taskSelf-contained; no node or host access neededprivacy-criticalTouches an invariant — needs a second reader

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions