Architecture¶
Titlani is organized in layers, from low-level wire format to high-level CLI.
Layer Diagram¶
graph TB
CLI["CLI (__main__.py + cli/)"]
Client["Client (client/)"]
Server["Server (server/)"]
Identity["Identity (identity/)"]
Content["Content (content/)"]
Protocol["Protocol (protocol/)"]
Encryption["Encryption (encryption/)"]
Tlacacoca["tlacacoca"]
GMAP["GMAP (gmap/)"]
CLI --> Client
CLI --> Server
CLI --> Identity
CLI --> Content
CLI --> Encryption
Client --> Protocol
Client --> Content
Client --> Identity
Client --> Tlacacoca
Server --> Protocol
Server --> Content
Server --> Encryption
Server --> GMAP
Server --> Tlacacoca
GMAP --> Content
GMAP --> Identity
Protocol --> Content
Identity --> Tlacacoca
Layers¶
Protocol (protocol/)¶
The foundation. Defines the Misfin wire format:
constants.py— Port, size limits, timeouts, wire format bytesstatus.py—StatusCodeenum and utility functionsrequest.py—MisfinRequest— parse and serialize request headersresponse.py—MisfinResponse— parse and serialize responses
This layer has no I/O and no dependencies beyond content/ (for message parsing).
Content (content/)¶
The gemmail message format:
gemmail.py—GemmailMessageandMisfinAddress— structured message representation with serialization
Pure data layer, no I/O.
Identity (identity/)¶
Misfin identity certificates:
certificate.py—MisfinIdentity,generate_identity_cert(),extract_identity(),normalize_fingerprint()
Uses cryptography directly for certificate generation (not tlacacoca) because Misfin needs a custom certificate layout. Uses tlacacoca for fingerprint utilities.
Encryption (encryption/)¶
At-rest encryption for stored messages:
manager.py—EncryptionManager— X25519 ECDH + HKDF-SHA256 + AES-256-GCM encryption. Loads public keys for server-side encryption and private keys for CLI decryption.
Uses cryptography directly. No dependency on tlacacoca.
Client (client/)¶
Async Misfin client:
session.py—MisfinClient— high-level API with TOFU, redirects, context managerprotocol.py—MisfinClientProtocol— low-levelasyncio.Protocolimplementation
Server (server/)¶
Async Misfin server:
config.py—ServerConfig— TOML configuration with validationhandler.py—MessageHandler(abstract) andFileMailboxHandlerprotocol.py—MisfinServerProtocol— two-phase buffering state machineserver.py—start_server()— server lifecycle with auto-cert, middleware, and separate GMAP port
GMAP (gmap/)¶
Gemini Mailbox Access Protocol for remote mailbox retrieval:
mailbox.py—GmapMailbox— per-mailbox JSON index (.gmap.json) with tag management and filesystem synchandler.py—GmapHandler— request routing and client certificate authentication, plusGeminiRequest/GeminiResponsetypesprotocol.py—GeminiServerProtocol— single-phaseasyncio.Protocolfor Gemini requests
CLI (cli/ and __main__.py)¶
Typer-based CLI providing send, serve, identity generate/info, tofu list/revoke, mail list/read/reply/delete, and version commands.
cli/display.py— Rich display helpers (tables, panels, formatting)cli/config.py—ClientConfig— client-side TOML config (XDG path viaplatformdirs)cli/mailbox.py— Shared mailbox resolution and message listing logic used bymail listandmail read__main__.py— Typer command definitions and CLI entry point
Data Flow: Sending a Message¶
- CLI parses arguments, loads identity certificate
- Client creates TLS context, opens connection to recipient's host
- Client builds
GemmailMessagefrom body/subject/sender info - Protocol serializes message to wire format (
misfin://...header + body) - Client sends bytes over TLS connection
- Client reads response, parses status code
- Client verifies server certificate against TOFU database
- Client follows redirects if applicable (up to 5 hops)
Data Flow: Receiving a Message¶
- Server accepts TLS connection, extracts client certificate
- Server Protocol buffers until CRLF (phase 1: header)
- Protocol parses header into
MisfinRequest - Server Protocol buffers until
content_lengthbytes received (phase 2: body) - Server runs middleware chain (rate limiting, access control)
- Handler validates hostname, checks mailbox exists
- Handler parses gemmail message, encrypts if a public key is loaded for the mailbox
- Handler stores as
.gemmail(plaintext) or.gemmail.enc(encrypted) file - Server sends response with status code and fingerprint
Data Flow: GMAP Remote Access¶
- Client connects to the GMAP port (default 1960) with TLS client certificate
- GeminiServerProtocol buffers until CRLF (single phase, no body)
- Gemini Protocol parses Gemini URL into path, query, hostname
- Gemini Protocol extracts client certificate from TLS transport
- Handler authenticates:
extract_identity(cert)extracts mailbox name - Handler verifies client cert fingerprint against registered identity
- Handler loads
GmapMailboxindex, syncs with filesystem - Handler routes by path (
/msgid/,/tag/,/untag/,/delete) - Mailbox performs the operation (list, tag, retrieve, delete)
- Handler returns
GeminiResponsewith Gemini status code and body
Design Decisions¶
Why asyncio.Protocol instead of streams?
The two-phase buffering model (header then body) maps naturally to asyncio.Protocol's push-based data_received callback. This avoids the overhead of stream buffering for a protocol with small, bounded messages.
Why separate identity certificate generation?
Misfin identity certificates need USER_ID for the mailbox name, which isn't a standard certificate field. Tlacacoca's generate_self_signed_cert() doesn't support this, so generate_identity_cert() uses cryptography directly.
Why a custom fingerprint format?
Misfin(C) uses plain lowercase hex fingerprints, while tlacacoca uses the sha256:hexdigest format. The normalize_fingerprint() bridge function handles this at every boundary point.
Why a separate port for GMAP? Misfin and GMAP have conflicting TLS requirements. Misfin must NOT request client certificates — senders present self-signed certs that would fail OpenSSL 3.x verification during the TLS handshake. GMAP must request client certificates for mailbox authentication. Since they can't share a TLS context, GMAP runs on its own port (default 1960). The GMAP specification explicitly allows any port.
Why a JSON index for GMAP tags?
GMAP requires per-message tags (Inbox, Unread, Trash, etc.), but the existing file-based mailbox storage has no metadata layer. A .gmap.json file per mailbox is simple, human-readable, and sufficient for typical mailbox sizes. For very large mailboxes (10k+ messages), a future migration to SQLite would be straightforward since the GmapMailbox API abstracts the storage.
Why lazy filesystem sync?
Messages delivered via Misfin are written directly as .gemmail files without updating the GMAP index. The GMAP handler syncs the index with the filesystem on each request, discovering new messages and removing deleted ones. This avoids tight coupling between the Misfin delivery path and the GMAP indexing layer — the Misfin handler doesn't need to know about GMAP at all.