feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server

This commit is contained in:
randogoth 2026-09-27 11:14:25 +03:00
parent eef7bf9b1d
commit d6fa097626
11 changed files with 911 additions and 232 deletions

View file

@ -85,12 +85,18 @@ def main() -> int:
run(bob + ["register", f"bob@{host}"])
check("bob registers on the reference server", True)
# --- reference client sends to the JS client
# bob needs alice as a known contact before he can accept her (§5.8).
run(bob + ["resolve", f"alice@{host}"])
# --- reference client sends to the JS client, unsolicited: bob has not
# accepted alice yet, so it lands in his requests tier, not the main one
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
print(run(bob + ["fetch"]).strip())
bob_list = run(bob + ["list"])
check("bob lists alice's message", "greetings" in bob_list, bob_list)
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_list).group(1)])
bob_requests = run(bob + ["list", "--requests"])
check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests,
bob_requests)
check("bob's main tier is still empty", "greetings" not in run(bob + ["list"]))
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)])
alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1)
check("bob reads the message", "hello bob" in bob_read, bob_read)
check("bob sees alice's key", alice_pub in bob_read)
@ -99,23 +105,34 @@ def main() -> int:
check("reference client emits the signed reply address",
"reply-to: smol://alice@" in bob_read, bob_read)
# --- JS client replies, through the bridge's WebSocket relay
# --- bob accepts alice (§5.8): pushed to the server right away, so her
# future mail reaches his main tier
accepted = run(bob + ["accept", f"alice@{host}"])
check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted,
accepted)
# --- JS client replies, through the bridge's WebSocket relay. Bob has
# accepted alice, so this carries the token issued to her — but it is
# still unsolicited from alice's side, landing in her requests tier.
ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}")
reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}",
"--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1)
check("bob sends through the bridge", bool(reply_id))
run(alice + ["fetch"])
alice_list = run(alice + ["list"])
check("alice lists bob's reply", "re: greetings" in alice_list, alice_list)
alice_requests = run(alice + ["list", "--requests"])
check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests,
alice_requests)
alice_read = run(alice + ["read", reply_id[:8]])
check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
check("reference client verifies the JS signature", "signature verified" in alice_read)
# bob's reply carries his signed smol:// address; a receiver that does
# not know the field must still preserve and display it (SPEC.md §5.5)
check("reference client preserves the signed reply address",
"reply-to:smol://bob@" in alice_read, alice_read)
bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read)
# --- rotation: the JS client rotates, the reference client walks the chain
# --- having now learned bob's accept token from that reply's Accept
# field, alice's next message to him carries a matching MAC and
# reaches his main tier even after he rotates his key
bob_rotated = run(bob + ["rotate"])
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
resolved = run(alice + ["resolve", f"bob@{host}"])
@ -124,7 +141,8 @@ def main() -> int:
"--anonymous"])
print(run(bob + ["fetch"]).strip())
bob_list = run(bob + ["list"])
check("bob reads mail addressed to the new key", "post-rotation" in bob_list)
check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list,
bob_list)
# --anonymous omits the reply address; the field list must show none
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
bob_list).group(1)])