Joinery Direct
Two people whose accounts live on Joinery instances can exchange messages without a third party in the middle. Joinery Direct is the channel that makes that work: a signed, sealed, consent-gated HTTPS exchange between two Joinery instances. Mail is its founding payload; any plugin can put its own payload on it. This document is the platform-level guide to the channel and the developer surface for putting a new payload on it.
The channel is off until an operator turns it on (joinery_direct_enabled). While
it is off the endpoint answers a plain not-found and nothing is published, so a
deployment that has not enabled it is indistinguishable from one that never heard
of the channel.
The channel in one pass
A delivery is a short sequence of HTTPS requests from the sending instance to the
receiving instance's advertised endpoint (/.well-known/joinery-direct):
- Preflight (
?step=preflight) — the envelope (sender, recipient, kind, protocol version, signed timestamp + nonce) and a manifest declaring each part's size, type, and role. No content. The receiver answersdeclinedoraccept; anacceptmay carry a vault public key and key generation to seal to, and opens a single-use delivery session. - Parts (
?step=part&nonce=…&index=N) — each part (body, HTML, each attachment) as its own request, raw bytes, sealed to the returned key when one was offered. One request per part is what keeps a large message from ever being bounded bypost_max_size, a request timeout, or the memory needed to hold a whole message: the ceiling is the largest single part. - Commit (
?step=commit) — the ordered per-part hashes of the sealed bytes and a signature over them, bound to the preflight nonce. The commit redeems the session once; parts are enforced against the admitted manifest, every hash is verified, and a replayed, late, or repeated commit is refused.
joinery_direct_session_ttl,
default 15 minutes), discarding any partial parts. A retry is a fresh preflight
with its own nonce.Both signed steps carry an Ed25519 instance signature verified against the sender domain's DNS capability record — no valid signature, no acceptance.
The pipe addresses people, not machines. Every delivery's recipient is a user
address (user@domain); consent, key discovery, and deferred authorization are all
per-user constructs, and sealing is always to the recipient user's vault key.
Machine-to-machine traffic (fleet coordination, server-manager operations) belongs
on the machine channels (FleetClient), not here.
Discovery: the capability record
A domain advertises Direct in DNS, published through the DNS record management driver flow:
SRV_joinery._tcp.<domain>→ host and port of the receiving endpoint (published as0 5 443 <host>). The target is the same host the domain's MX names: the mail host on a colocated deployment — a DNS-only name for the box, never a CDN- or proxy-fronted web host — and the relay on a relay-fronted one, whose address the relay exists to conceal.TXT_joinery-key.<domain>→v=joinery1; k=<key id>; p=<base64 Ed25519>, one record per publishable key so a rotation can stage a second key while senders may still be quoting the first.
Every DNS driver publishes SRV. Vendors model it three different ways and each
driver translates: some take the whole RDATA verbatim, some split the priority
out and carry weight port target as content (the shape they already use for
MX), and some decompose all four fields — with GoDaddy and Namecheap additionally
moving the _joinery and _tcp labels out of the record NAME and into fields of
their own. tests/dns/dns_srv_drivers_test.php pins every one of those mappings
in both directions, because a wrong field mapping does not throw: it writes a
record that looks published and resolves to nothing.
The capability provider hook (DnsProvider::supportsType()) remains for a future
vendor whose mapping cannot be verified. A driver that answers false there has
its record reported as add by hand, with the value ready to copy, while
everything else in the plan still publishes with the button — because a record
written into the wrong fields is worse than one the operator is told to add.
Sending: the Direct client
All sending goes through one call, for every kind:
$result = JoineryDirect::send($recipient_address, $kind, $parts, $options);The client owns the whole shared layer — capability lookup (cached, positive and negative), the SSRF-guarded connection, preflight, sealing, and transfer — and returns a typed result, never a behavior:
| Result | Meaning |
|---|---|
delivered | Accepted and transferred; the result records whether parts were sealed and to which key generation |
declined | The receiver answered declined — this recipient does not accept this kind from this sender |
no_capability | A missing precondition on either half of the handshake: the recipient domain publishes no capability record, this deployment holds no signing identity for the sender, or the sender domain's own DNS records are not published — checked before the wire, since the recipient verifies our signature against the key our domain publishes |
no_sealing | The caller passed require_sealed and the preflight returned no recipient key — refused between preflight and transfer, so no content byte crossed the wire |
failed | Connection, timeout, or verification failure at any step |
require_sealed (option): the client's default is opportunistic sealing —
parts cross plaintext-over-TLS when the far side publishes no key. A caller whose
policy forbids that trade (a Guarded conversation) passes
'require_sealed' => true and gets no_sealing instead of a transfer. The
refusal is final for as long as the far side has no vault: retrying asks the same
instance the same question.What a result means belongs to the calling kind, not the client. Mail's
transport adapter (DirectMailTransport, registered into EmailSender) maps
everything short of delivered to the ordinary provider path. The SMTP fallback
exists only in that adapter — no other kind's failure ever produces an SMTP send.
Three client rules worth knowing:
- The recipient key is never cached. It arrives in each preflight
acceptand is used for that delivery only. A cached key can straddle a vault rotation and seal a message nobody can ever open — worse than not sealing. - The SRV target is hostile input. The client resolves it through
SafeHttpClient: private/reserved/loopback addresses blocked, connection pinned to a validated public IP, port restricted to 443 or ≥ 1024, TLS verified against the SRV hostname, redirects never followed. Any failure is simplyfailed. - Parts are descriptors, not strings. Each part names its role, content type, and (for attachments) filename, with content as bytes or a file path — peak memory scales with the largest single part (sealing is one-shot), which the per-part size cap bounds. A payload past this instance's own caps is refused locally rather than costing the recipient a preflight.
Receiving: the framework and the wire discipline
The endpoint is a route (ajax/joinery_direct.php, built in the shape of the
inbound email webhook, not a service). For every kind, identically, the framework
runs:
- Protocol version, then instance signature verification against the sender domain's capability record — resolved through a cached, negative-cached, peer-rate-limited lookup.
- A per-instance rate limit on the identity the signature established.
- Freshness and replay: the signed timestamp must be within −5/+1 minutes, the nonce unseen (10-minute replay cache holding only opaque nonces, so it works while a vault is locked).
- Manifest bounds: the declared parts and sizes are checked against declared caps (max parts, bytes per part, total bytes) — exceeding any is a request-level refusal, identical for every recipient and kind.
- Kind dispatch from the registry; a kind this instance does not serve — or an unimplemented protocol version — is refused at request level.
- Recipient resolution. A domain this deployment does not host is a request-level
refusal, because it is a fact about the deployment rather than about a
recipient.
existsis an identity fact — is there an addressable recipient behind this local part — never one kind's routing preference. - The kind's declared recipient requirement, then its authorization gate — at
Standard only (see Security tiers), both folded into one
declined: a stranger, a nonexistent address, and a recipient this kind cannot land on (mail to a forwarding alias, chat to a shared mailbox) are indistinguishable. At the sealed tiers both defer to the same local disposition moment. - On accept: the key answer and a single-use delivery session holding the admitted manifest; parts arrive one request each and are enforced against it; the commit redeems the session once, verifies every sealed-byte hash, and then either ingests (Standard) or leaves the delivery held for the recipient's unlock.
accept/declined), request-level refusals are a
separate indistinguishable bucket carried as HTTP statuses, a Private or Fortress
receiver accepts unconditionally (with a decoy key for addresses that do not
exist), nothing is ever bounced, and rate limiting is per verified sending
instance (a declared, tunable setting).Serving a kind: the plugin surface
A plugin puts a payload on the pipe by declaring it in plugin.json:
"directKinds": {
"chat": { "handler": "includes/ChatDirectHandler.php", "gate": "contacts", "recipient": "owner" }
}The string shorthand "chat": "includes/ChatDirectHandler.php" is equivalent and
means the handler supplies its own gate; class names the handler class when it
differs from the filename.
recipient declares who the kind can land on — a requirement over the facts
the address resolver reports, judged by the framework at every gate site so no
handler re-implements it:
- absent — any existing recipient.
"owner"— a single consenting user must resolve. Chat declares this: a message needs a person whose conversation list it lands in, so a shared mailbox declines while a forwarding alias with one grantee chats fine — forwarding is an email routing choice, not an identity fact."email_store"— email delivered here must land in a local store and only a local store. Mail declares this: a Direct payload never becomes a MIME document, so a forwarding leg cannot run; the decline sends the message back to SMTP, which runs both legs. An unknown requirement word makes the declaration unusable (the kind refuses as unserved) rather than silently meaning "anyone".
declined a stranger gets; at the sealed tiers it defers with the gate and
becomes a local disposition (gate_accepted false into ingest — mail files the
message through ordinary classification, chat discards it). Core kinds are declared the same way in
direct_kinds.json at the public_html/ root. Mail is declared by the *mailbox
plugin, which is what makes deactivating that plugin remove the kind from the
served set. The registry is plain instance configuration, readable without loading
handler code: a kind that is not served refuses exactly like an unknown one.A handler is two pure functions — that is the entire surface:
gate(envelope): bool— "does this recipient accept this kind from this sender," nothing else. It never sees vault lock state and never composes a wire response. Under Private and Fortress it is not called at receive at all; the framework accepts unconditionally and defers the gate to unlock.ingest(envelope, parts, gate_accepted)— store the delivered payload in the kind's own model. It runs only after hash verification. On the live path it runs only on accept. On the deferred path it runs at unlock for every spooled delivery, carrying the deferred gate's outcome: because the sender was already answeredaccept, a deferred decline is a local disposition, not a drop — mail files such a message where the ordinary path would have (ordinary/spam, no verified mark).
DirectDeferIngest. The framework holds the delivery
(state HELD, parts intact) and re-runs ingest at the recipient's next unlock; the
wire answer is unchanged, so lock state never leaks. This is for "not now", never
"not ever": a genuinely unstorable payload is the handler's to log and drop,
because a held delivery is retried at every unlock until the retention sweep
reclaims it.The envelope hands ingest the verified-sender fact and transport tag, so a kind can drive its own UI the way mail's verified-direct mark does — applied by the receiver, never reproducible from message content. On the deferred path the envelope also carries the recipient's in-window vault secret, which is the only way a sealed part is ever opened.
Authorization is per-kind. The contact gate — full sender address plus a
sending domain bound to the verified instance signature, matched against
imc_mailbox_contacts — is exported as a canned gate, declared as
"gate": "contacts" in the directKinds entry; the framework then runs it and
never calls a handler gate, so such a handler implements only ingest. Mail uses
it; a new kind declares it or supplies its own gate. Handlers receive typed
envelope and part objects — named accessors for sender, recipient, manifest, roles,
and content — never raw wire payloads.
Anything a kind needs to say travels in its parts, never in new envelope
fields; that is what keeps the envelope kind-independent. Mail's own metadata
(subject, From display name, Message-ID, threading headers) rides as a part typed
message/rfc822-headers.
A loopback send lets the test estate exercise a handler's gate/ingest on one
instance with no DNS or network: JoineryDirect::send with loopback set runs the
full receive framework locally. It is a test-tier tool, not a delivery path — real
same-instance mail never needs Direct.
Security tiers
Contacts seal under the same rule as the mail beside them (vault present and the domain seals content), so the tiers behave as:
- Standard — contacts are plaintext; the gate runs live at receive; a
non-contact gets
declinedon the wire. No key is offered, so parts cross under TLS unsealed and ingest runs live — which is what the mailbox stores anyway, and what lets ingest happen without an open unlock window. A stranger, a removed contact, a blocked sender and an address that does not exist all get one byte-identicaldeclined. - Private / Fortress — contacts are sealed, and both tiers share one wire
posture, locked or unlocked: the receiver accepts unconditionally — never a live
declined, no lock-state oracle — and returns a key for every address that exists or not, with a deterministic decoy (reporting key generation 1) standing in for addresses that do not, so existence, contact membership, and block status are unknowable from the wire. Authentication (signature verification, hash checks) runs at receive; authorization defers: the framework spools the accepted delivery in the Direct spool — keyed by kind, holding the envelope, verified-sender fact, and sealed parts, nothing needing the vault — and drains it at the next unlock, running each delivery's deferredgatetheningest. A spooled delivery for a deactivated plugin is held sealed until reactivation or expires quietly with the spool's retention; nothing is ever returned to the sender. Held parts are sealed straight to the recipient's vault keypair, so the spool is areseals: truevault consumer: on a key rotationDirectSpoolDrain::resealForUser()re-seals every held delivery's sealed parts to the new keypair (each delivery atomically, alongside itsjdp_key_generation), and a part that cannot be re-sealed refuses the ceremony. A delivery still staging rides out the rotation untouched — its sender is mid-transfer sealing to the key it discovered, and an undrainable staging row is an abandoned transfer the retention sweep reclaims. The gate decides only elevation, never placement: for mail, a deferred decline hands the message to the same classification ordinary mail gets — content spam scan and filter rules included. The decoy's domain secret is minted on first use and kept, because a key that changed between probes of one address would itself be the tell. At Fortress the relay answers preflights, so that same secret travels to it in the relay map — a decoy that differed between the box and its relay would be a distinguisher in itself.
Key custody
Outbound Direct is signed by the box, with custody mirroring DKIM's:
- Box custody — the domain's Ed25519 secret key is held under
SecretBoxand unwrapped per send. This is what an ordinary deployment uses, so it signs without anyone being logged in. - Vault custody — a domain that seals content and names an owner keeps its signing key sealed to that owner's vault public key and unwraps it in-window, per send. A locked box then cannot sign in anyone's name; the send falls back instead. A vault key rotation re-seals it alongside the message DEKs and the protected-domain DKIM keys.
DirectSigningIdentity::rotate): a new key id is minted and
the old row stays publishable until it is retired, because a sender that cached the
capability record may still be quoting the old id.The relay at Fortress
A Fortress deployment's SRV record targets its relay, in both directions. An SRV record pointing at the origin box would advertise in public DNS exactly the address the relay exists to conceal, so the target is the relay — the same posture MX already takes.
The relay serves the channel from the same Go binary that seals its mail, in a
third mode (relay-sealer direct-serve), installed as the joinery-direct
service by provision_relay.sh from relay version 2.5:
- Inbound,
:443— the public endpoint. TLS is terminated in-process with an ACME certificate obtained over TLS-ALPN-01 on that same port; there is no web server and no certbot on the machine, because the relay's smallness is the security property. A verified delivery is written to the tenant's spool as a.directcontainer beside the.sealblobs mail already uses, and travels the WireGuard pull that already exists — no new transport and no new credential. - Outbound, tunnel-only — an egress listener on the WireGuard address. The box builds and signs a complete request and the relay makes it, so the recipient sees the relay's address and never the box's.
.direct container, and the box re-verifies them against the sender
domain's DNS-published key before it stores anything — deriving the verified
sender from the signed envelope, never from the relay's assertion, and re-checking
each part's bytes against the signed hashes. So a forged sender never reaches the
contact gate even if the relay is compromised. The contact gate itself needs the
sealed contact list, so it runs on the box at the recipient's next unlock: a relayed
delivery lands in the same Direct spool a locally accepted one does, and the
ordinary unlock drain gates and ingests it. One deferred path, not two — which is
what keeps the no-bounce, held-plugin and decline-is-a-local-disposition rules in
one place.The relay never signs, and the box never trusts it to. The instance signing key stays on the box, exactly as DKIM's does; the relay transports an app-signed request it cannot alter, and holds no key with which to forge a sender or reproduce a tampered part's signature. With sender sealing it also forwards ciphertext it cannot read, which makes it a pure address-hiding forwarder rather than a component that must be trusted with content — the worst a compromised relay can do is drop or delay a delivery.
The relay is kind-agnostic in code. The tenant's served-kind list, decoy
secret, rate limits and spool caps all travel in the relay map as data, and the
relay compares opaque kind strings — so a new kind, core or plugin, reaches the
fleet as a map update, never a relay release or fleet upgrade. RELAY_VERSION
moves only when the shared layer itself changes.
The interop is pinned. A signature is only worth anything if both ends agree
byte for byte on what was covered, and a drift between DirectProtocol.php and
the relay's direct_protocol.go would not throw anywhere — every delivery would
simply fail verification, which a sender reads as "unreachable" and downgrades to
the fallback. plugins/mailbox/tests/direct_wire_gate.sh has PHP emit the signing
bytes and Go emit them for the same deliberately awkward fixture and diffs the
two, on every safe run.
A tenant whose relay is behind simply has no capability record published yet, so senders fall back and nothing breaks. Publishing the SRV record is the last step of enabling Direct for a domain, never the first.
Blocking and abuse
- Remove from contacts — a neutral downgrade; the sender's next attempt gets
declined(for mail, that means the ordinary provider path). - Block — remove the contact plus a sender-matched
mark_spaminbound filter rule. There is no separate block store and no gate-time block lookup: a blocked sender is a non-contact on the wire, indistinguishable from any stranger, and the fallback that follows is filed as spam. - The endpoint rate-limits preflights per verified sending instance (sliding
window,
joinery_direct_preflight_limitperjoinery_direct_preflight_window, default 120 per rolling 2 minutes), and capability lookups are cached and rate-limited by connecting peer so attacker-named sender domains cannot drive unbounded outbound DNS. Both reuse the platform's existing limiters —RequestLogger::check_rate_limitfor the per-peer check, window counts over Direct's own request log for the per-instance check — not a new engine. - Storage is bounded in bytes, not just counts: manifest size caps at preflight
(
joinery_direct_max_parts,joinery_direct_max_part_bytes,joinery_direct_max_total_bytes), and per-domain plus per-address byte caps on the Direct spool at the sealed tiers (joinery_direct_spool_domain_cap_bytes,joinery_direct_spool_address_cap_bytes), refused at request level. Decoy addresses accrue phantom bytes, so a full spool refuses identically for real and nonexistent addresses; a cap refusal downgrades mail to the provider path, losing nothing. - Nothing is silent to the operator: request-level refusals and send-side downgrades are counted in Direct's request log and surfaced on the mailbox admin Logs tab, so a clock-drifted box that quietly loses Direct is diagnosable. That panel names the diagnosis, not the symptom — "every outbound attempt fell back" is what an unpublished record or a drifted clock looks like from there.
Mail: what the channel adds
Mail's own delivery, storage and classification are unchanged; Direct adds a lane and a mark.
- The transport adapter attempts Direct per recipient. Recipients it delivered are dropped from the message, so the ordinary send that follows is never a duplicate for them. A message carrying Cc or Bcc stays whole on the ordinary path — Direct addresses one person at a time, and splitting a message across two paths with different header sets is not something the channel can express.
- A delivered message is stored from its parts directly into
iem_inbound_email_messagesandimc_inbound_message_attachment— no MIME document is assembled and taken apart again, so attachments land as individual rows, listable and previewable, without ever having been readable in transit. Attachments transfer as bytes, with none of MIME's base64 inflation. iem_transportrecords how the message arrived andiem_direct_verifiedwhether it earned the mark. The mark asserts exactly two things — the sending instance was cryptographically verified, and the sender is in
Where the pieces live
- Sending:
includes/joinery_direct/JoineryDirect.php, the mail adapterplugins/mailbox/includes/DirectMailTransport.phpregistered intoEmailSender. - Receiving: the
/.well-known/joinery-directroute (ajax/joinery_direct.php),includes/joinery_direct/DirectReceiver.php, and the kind registry fromdirect_kinds.jsonplusdirectKindsdeclarations. - Consent:
imc_mailbox_contacts(see the Mailbox plugin overview). - Storage for the mail kind:
iem_inbound_email_messagesandimc_inbound_message_attachment, tagged with the delivering transport. - The spool, sessions, replay cache, capability cache and signing identities:
jdp_direct_spool/jda_direct_spool_parts,jds_direct_sessions,jdn_direct_nonces,jdc_direct_capability_cache,jdi_direct_identities. - Capability records: the DNS record management plan/driver flow (see DNS Management).
- The safe outbound path every SRV target is reached through:
includes/SafeHttpClient.php. - The relay's side:
plugins/mailbox/provisioning/relay-sealer/direct_*.go(endpoint, capability lookup, state, crypto, spool artifact, egress), installed as thejoinery-directservice byprovision_relay.sh. - The box's side of the relay path:
DirectRelayEgress(outbound, registered intoJoineryDirect),DirectRelayIngest(inbound, called byRelaySpoolConsumerfor.directentries), and the Direct block of the fragmentRelayMapExporterbuilds.