// The app-level client: the same flows the screens have always called — // connect with pinning, fetch with verification and acknowledgment, send // with sent copies, contacts and rotation — now as one thin layer over // fumi-core through the native binding. Every network operation runs on an // isolate; the reads the UI makes per frame stay synchronous. import "dart:io"; import "dart:math"; import "dart:typed_data"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/store.dart"; class RefreshOutcome { final String message; final bool warn; const RefreshOutcome(this.message, this.warn); } class FetchSummary { final int stored; final List rejected; const FetchSummary(this.stored, this.rejected); } class OpenedRecord { final String id; final String? sender; // base32 final int? time; final Map fields; final String body; final String? error; const OpenedRecord(this.id, {this.sender, this.time, this.fields = const {}, this.body = "", this.error}); String get subject => error == null ? (fields["subject"] ?? "") : ""; } class SmolClient { final SmolStore store; /// Trust warnings (§4/§8) the UI must not let the user miss; the app wires /// this to a persistent banner in app_widget.dart. void Function(String message)? onWarning; SmolClient(this.store); void _warn(String message) => onWarning?.call(message); FumiNative get _native => store.native; SmolFfi get _ffi => SmolFfi.open(); /// Resolves the host the way that works everywhere this app runs: the /// platform resolver. On Android the native getaddrinfo that fumi-core's /// connect would use can be dead for app processes while this path works, /// so the facade resolves here and hands the IP across as a dial hint — /// the hostname keeps every identity role. IP literals dial themselves. Future _dial(String host) async { final parsed = InternetAddress.tryParse(host); if (parsed != null) return parsed.address; try { final addresses = await InternetAddress.lookup(host); if (addresses.isEmpty) { throw SmolError("could not resolve $host"); } return addresses.first.address; } on SocketException catch (err) { throw SmolError("could not resolve $host: ${err.message}"); } catch (err) { if (err is SmolError) rethrow; throw SmolError("could not resolve $host"); } } /// The account host's dial hint, for the ops that connect home. Future _accountDial() async { final addr = accountAddress(); if (addr == null) return ""; return _dial(addr.host); } // Opening an envelope is microseconds native; the results never change, so // they are kept like the Dart core kept them. Failures cache too. final _openedCache = {}; SmolIdentity? get identity => store.identity(); Uint8List? get master => store.master(); SmolAddress? accountAddress() { final text = _ffi.accountAddress(_native.store!); if (text == null) return null; return parseAddress(text); } // --- identity setup ------------------------------------------------------------ /// A fresh master secret at rotation index 0. Only this local step; /// nothing is sent until [registerAccount]. Future createIdentity() async { final fresh = _randomMaster(); store.setMaster(fresh); return fresh; } Uint8List _randomMaster() { // The platform CSPRNG: 32 random bytes are the whole identity. final rng = Random.secure(); return Uint8List.fromList( List.generate(32, (_) => rng.nextInt(256))); } /// §2: a master alone does not say which rotation index a server has bound, /// so restoring resolves the address and walks indices until one derives /// the key RESOLVE returned, then binds the account locally. Future restoreAndRecall(String masterHex, String addressText) async { Uint8List master; try { master = _unhex(masterHex.trim()); } on Exception { throw const SmolError("master must be 64 hex characters"); } if (master.length != 32) { throw SmolError("master is ${master.length} bytes, expected 32"); } final addr = parseAddress(addressText); store.restoreMaster(master, 0); await _restore(addr); master.fillRange(0, master.length, 0); return addr; } Future _restore(SmolAddress addr) async { // fumi's restore would tolerate an unpinned server (sec 8 trust on // first use), but this app's onboarding teaches the pin up front: // registration and fetching demand it anyway (sec 4), so restoring is // stopped at the pin step rather than letting the account bind to a // server whose key nobody verified. if (store.serverPin(addr.host, addr.port) == null) { throw SmolError("no pinned key for ${addr.host}. Obtain it from the " "operator through a trusted channel, then pin it in settings."); } try { // restore resolves, walks the rotation indices and writes the account // state; the account handle is rebuilt inside the binding. await _native.restore(addr.short, dial: await _dial(addr.host)); } on NativeSmolException catch (err) { throw SmolError(err.message); } } void pinServer(String host, String keyB32, int port) => store.pinServer(host, keyB32, port); Future registerAccount(String addressText, {String token = ""}) async { final addr = parseAddress(addressText); try { await _native.register(addr.short, invite: token.isEmpty ? null : token, dial: await _dial(addr.host)); } on NativeSmolException catch (err) { throw SmolError(err.message); } } /// Recall binds the identity to its registered address without /// re-REGISTER — the same resolve-and-walk a restore does (§2). Future recallAccount(String addressText) async { final addr = parseAddress(addressText); await _restore(addr); return addr; } // --- fetch ----------------------------------------------------------------- Future fetch({bool reset = false}) async { if (master == null) throw const SmolError("no identity yet"); if (accountAddress() == null) { throw const SmolError("not registered; register an address first"); } final Map summary; try { summary = await _native.fetch( keep: store.leaveOnServer(), reset: reset, dial: await _accountDial()); } on NativeSmolException catch (err) { throw SmolError(err.message); } _openedCache.clear(); if (summary["cancelled"] == true) { _warn("fetch cancelled; partial results kept"); } return FetchSummary( summary["stored"] as int, [ for (final r in (summary["rejected"] as List).cast>()) "${r[0]}: ${r[1]}", ], ); } /// Raises the cancellation flag; a running fetch stops between envelopes /// and returns a partial summary. void cancelFetch() => _native.cancelFetch(); // --- delete ------------------------------------------------------------------ /// Deletes a message locally, and from the server too if it might still be /// sitting there (only possible when "leave mail on server" was on when it /// was fetched — §10). Sent copies are local-only (§5.6). Future deleteMessage(String folder, MailRecord record) async { if (folder != "sent" && record.keptOnServer) { if (accountAddress() == null) { throw const SmolError( "not registered; cannot reach the server to delete this message"); } try { await _native.delete([record.id], dial: await _accountDial()); } on NativeSmolException catch (err) { throw SmolError(err.message); } } await store.deleteMessage(folder, record.id); _openedCache.remove(record.id); } // --- accept tokens (§5.8) -------------------------------------------------------- /// Admit a contact to the main tier; their token travels in our next /// message to them. Pushes the changed set to the server right away. Future acceptContact(String address) async { try { return await _native.accept(address, dial: await _accountDial()); } on NativeSmolException catch (err) { throw SmolError(err.message); } } /// Withdraw a contact's accept token; their mail lands in requests from /// their next message on. Future blockContact(String address) async { try { await _native.block(address, dial: await _accountDial()); return 0; } on NativeSmolException catch (err) { throw SmolError(err.message); } } // --- compose and send ----------------------------------------------------------- /// Sends one message (§5, §6.1): recipient selection prefers a key we /// already trust, then RESOLVE with trust on first use; an accepted /// correspondent gets our token and a §5.6 sent copy is kept. Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { if (master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); final Map sent; try { sent = await _native.send(addr.short, body, subject: subject.isEmpty ? null : subject, replyTo: replyTo, dial: await _dial(addr.host)); } on NativeSmolException catch (err) { throw SmolError(err.message); } if (sent["warning"] != null) { _warn(sent["warning"] as String); } return addr.short; } // --- reading ----------------------------------------------------------------- /// Opens one sealed message: the described view the reader shows. Sync — /// one unseal is microseconds native, and the old Dart core did the same /// work at fifty times the cost. OpenedRecord describe(MailRecord row) { var entry = _openedCache[row.id]; if (entry == null) { if (_native.account == null) { // No account handle: no identity to unseal with. entry = OpenedRecord(row.id, error: "no identity yet"); _openedCache[row.id] = entry; return entry; } try { final described = _ffi.describe(_native.store!, _native.account!, row.id); entry = OpenedRecord( row.id, sender: described["sender"] as String, time: described["time"] as int, fields: (described["fields"] as Map).cast(), body: described["text"] as String, ); } on NativeSmolException catch (err) { entry = OpenedRecord(row.id, error: err.message); } _openedCache[row.id] = entry; } return entry; } /// The Reply-To address carried inside the message, but only when it is a /// full smol:// URI whose key matches the signer (§5.7); anything else is /// ordinary text. SmolAddress? replyAddress(OpenedRecord opened) { final claim = opened.fields["reply-to"]; if (claim == null || opened.sender == null) return null; try { final parsed = parseAddress(claim); if (parsed.identity != null && parsed.identity == opened.sender) { return parsed; } } on SmolError { // malformed claim: display, never bind } return null; } /// Bind a user-supplied address to the key that signed a message. A smol:// /// address carries its own key (verified); a short address is resolved and /// the result kept on first use. Anything that binds a different key is /// refused. Future nameSender(String text, String senderKey) async { final addr = parseAddress(text.trim()); if (addr.identity == null) { await refreshContact(addr.short); final known = store.contact(addr.short); if (known == null) { throw const SmolError("could not resolve that address"); } if (known.key != senderKey) { throw const SmolError( "that address carries a different key than this message's sender"); } return; } if (addr.identity != senderKey) { throw const SmolError( "that address carries a different key than this message's sender"); } await store.saveContact(addr.short, senderKey, verified: true); } /// A signed Reply-To is the sender's own claim, so it saves as verified — /// but never over an address already pinned to a different key (§8). Future saveReplyAddress(SmolAddress addr, String senderKey) async { final existing = store.contact(addr.short); if (existing != null && existing.key != senderKey) { throw SmolError("${addr.short} is already known with a different key — " "verify out of band before replying"); } await store.saveContact(addr.short, senderKey, verified: true); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and // surfaced; anything else requires out-of-band verification. Future refreshContact(String address) async { final addr = parseAddress(address); if (addr.identity != null) { throw const SmolError( "that address already carries a key; use import instead"); } final known = store.contact(addr.short); final Map resolved; try { resolved = await _native.resolve(addr.short, dial: await _dial(addr.host)); } on NativeSmolException catch (err) { throw SmolError(err.message); } final change = resolved["change"] as String; if (known == null) { return RefreshOutcome( "${addr.short} pinned (trust on first use)", change == "newUnverified"); } switch (change) { case "none": return RefreshOutcome("${addr.short}: key unchanged", false); case "rotated": _warn("${addr.short} rotated its key; a signed chain confirms it.\n" "now ${resolved["key"]}"); return RefreshOutcome( "${addr.short} rotated its key; a signed chain confirms it.\n" "now ${resolved["key"]}", true); default: return RefreshOutcome( "${addr.short} presents a different key with no valid rotation chain.\n" "Verify out of band, then import the new smol:// address.", true); } } // Bind a smol:// address to the key it carries (§8's strong path). Future importContact(String text) async { final addr = parseAddress(text.trim()); if (addr.identity == null) { throw const SmolError("import needs a smol:// address carrying a key"); } await store.saveContact(addr.short, addr.identity!, verified: true); } // --- rotation ----------------------------------------------------------------- /// §7: rotate to the next index's derived key and rebind the account with /// a signed certificate. The superseded key stays derivable from the /// master, since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { if (identity == null || master == null || accountAddress() == null) { throw const SmolError("rotate needs a registered account"); } try { await _native.rotate(dial: await _accountDial()); } on NativeSmolException catch (err) { throw SmolError(err.message); } _openedCache.clear(); return identity!; } // A full wipe: every secret and every stored envelope. The UI must confirm. Future wipe() async { await store.wipe(); _openedCache.clear(); } Uint8List _unhex(String text) { if (text.length % 2 != 0) { throw const SmolError("odd-length hex string"); } return Uint8List.fromList([ for (var i = 0; i < text.length; i += 2) int.parse(text.substring(i, i + 2), radix: 16), ]); } }