From 2c1c2c8e99a66183684e144e2fb3a38f5425cb16 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 10 Oct 2026 18:26:13 -0700 Subject: [PATCH] Add owner-controlled personal room mailboxes and WiFi management --- .github/workflows/run-unit-tests.yml | 5 + README.md | 2 +- docs/cli_command_availability.md | 9 + docs/room_services.md | 161 +++- docs/test-results/room_mailboxes.md | 77 ++ examples/simple_room_server/MyMesh.cpp | 172 ++++- examples/simple_room_server/MyMesh.h | 5 + src/helpers/RoomMailProtocol.h | 443 +++++++++++ src/helpers/RoomMailStore.h | 729 +++++++++++++++++++ src/helpers/RoomMailWeb.h | 247 +++++++ src/helpers/RoomWebJson.h | 5 + src/helpers/esp32/RoomWebHtml.h | 247 ++++++- test/fixtures/room_client_paths.cpp | 14 +- test/fixtures/room_topic_delivery/test.cpp | 44 +- test/fixtures/room_web_service/boundary.json | 2 +- test/fixtures/room_web_service/service.cpp | 214 +++++- test/test_official_room_app_compatibility.py | 4 + test/test_room_client_paths.py | 209 ++++++ test/test_room_login_integration.py | 7 + test/test_room_mail_protocol.py | 377 ++++++++++ test/test_room_mail_store.py | 331 +++++++++ test/test_room_mail_web_ui.py | 313 ++++++++ test/test_room_topic_delivery.py | 8 +- test/test_room_web_service.py | 6 +- 24 files changed, 3607 insertions(+), 24 deletions(-) create mode 100644 docs/test-results/room_mailboxes.md create mode 100644 src/helpers/RoomMailProtocol.h create mode 100644 src/helpers/RoomMailStore.h create mode 100644 src/helpers/RoomMailWeb.h create mode 100644 test/test_room_mail_protocol.py create mode 100644 test/test_room_mail_store.py create mode 100644 test/test_room_mail_web_ui.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0a11fb9e9..3ff790e96 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -95,6 +95,8 @@ jobs: python3 -m pip install cryptography sudo apt-get update sudo apt-get install -y libssl-dev + mkdir -p .pio/libdeps/room-mail-test + pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/room-mail-test - name: Verify ArduinoJson pin run: | @@ -134,6 +136,8 @@ jobs: python3 -B test/test_room_history_integration.py -v python3 -B test/test_room_board_store.py -v python3 -B test/test_room_board_protocol.py -v + python3 -B test/test_room_mail_store.py -v + python3 -B test/test_room_mail_protocol.py -v python3 -B test/test_room_web_mailbox.py -v python3 -B test/test_room_web_http.py -v python3 -B test/test_room_web_service.py -v @@ -564,6 +568,7 @@ jobs: python3 -B test/test_sensor_webconfig_capabilities.py -v python3 -B test/test_webconfig_ui_runtime.py MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_web_ui.py -v + MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_mail_web_ui.py -v - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/README.md b/README.md index d1cf4f4cf..63dc047b2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ * **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts. * **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature. * **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser. -* **[Room topics, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat, a persistent information board, and an admin dashboard for all users' routes, delivery, activity, catch-up and access. The existing app's chat and catch-up formats remain supported. +* **[Room topics, mailboxes, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Regular users manage personal mailboxes with public or selected-sender access, explicit receipts, and mailbox-only delivery for sleeping trackers. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat, an information board, and an admin dashboard for mailboxes, routes, delivery, activity, catch-up and access. The existing app's chat and catch-up formats remain supported. * **[Full firmware profiles, Companion transports, and ESP32 bridges](https://mikecarper.github.io/MeshCore/full_profiles/).** Full combines the exact board and role's supported features with runtime controls: Companion transports and firmware serving, or qualified ESP32 infrastructure services and MQTT/ESP-NOW/RS-232 bridges. Includes capacity exceptions, matching application slots, exact-board/role partition migration packages, and nRF52 Full/Reduced supported-sensor packages with LoRa OTA. The picker links supported migration routes; USB layout changes use the matching merged image, since an app-only image cannot change the partition table. * **[Operational reporting and alerts](https://mikecarper.github.io/MeshCore/management_reports/).** Adds optional management reports with firmware version, bootloader version, LoRa update capabilities, uptime, and historical voltage and temperature ranges. * **[Set time using the LoRa network](https://mikecarper.github.io/MeshCore/cli_commands/#estimate-and-correct-infrastructure-node-time-after-startup).** Infrastructure nodes can estimate time from fresh mesh evidence and correct their clocks when a consensus is available. Successful manual, GPS, or NTP synchronization takes precedence until reboot. diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 8c856f497..7c4760d88 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -19,6 +19,15 @@ Infrastructure `get password` is available locally in every profile; LoRa requests cannot read it. `get acl` and stored packet `log` are infrastructure features, not Companion contact or live USB-logging commands. +Room users also have an owner-only command set described in +[Personal mailboxes](room_services.md#personal-mailboxes): `mail settings`, +`mail mode`, `mail allow`, `mail deny`, `mail delivery`, `mail check`, +`mail inbox`/`mail list`, `mail read`, `mail ack`, and `mail delete`. +Authenticated room writers can use `mail send`; readers cannot send. +These work as encrypted CLI requests or ordinary direct `!mail ...` messages +to the room. Regular users can also read `get room.board` and +`get room.board.read`; board publishing and deletion remain administrative. + Build columns mean: - **Standard** - the ordinary non-MQTT artifact, without an explicit `ota` diff --git a/docs/room_services.md b/docs/room_services.md index 5ead1e7cd..4d56f24d6 100644 --- a/docs/room_services.md +++ b/docs/room_services.md @@ -2,13 +2,170 @@ -# Room history, moderation, and information board +# Room history, personal mailboxes, and information board Room Server firmware keeps one room per radio. Existing app login, chat, catch-up, and Access Control formats remain supported. The information board and local Wi-Fi chat use the browser page below; the stock mobile app does not have an information-board screen. +## Personal mailboxes + +The single room also provides personal mailboxes. Each mailbox belongs to a +complete public-key identity, independently of the public chat backlog. Regular +room readers and writers can choose their own access policy and delivery mode; +administrator permission is not required. + +| Access | Who may leave mail | +| --- | --- | +| Closed (default) | The owner only | +| Public | Any authenticated room writer or administrator | +| Private | Up to eight selected full public-key identities, plus the owner | + +Read-only users can manage and read their own mailbox but cannot send mail. +Room bans still apply to both senders and recipients. A private allow list does +not grant room login or writing permission; approved senders must also have +room writer access. Reading, acknowledging and deleting mail always use the +authenticated owner's identity, never a name or shortened key supplied in a +request. Changing access does not delete already accepted mail. + +Mailboxes start closed and are created only when the owner saves a policy. +There are at most 32 mailbox owners, 16 queued messages across the radio, four +queued messages per owner, and eight selected senders per mailbox. Messages +contain 1-512 UTF-8 bytes. A full queue rejects new mail without replacing old +mail. Available filesystem space can impose a smaller limit. + +The server stores mail separately from public chat. Mail is encrypted on each +LoRa connection, but is plaintext in the server's filesystem: the room operator +is trusted. This is not end-to-end encryption between sender and recipient. +Other ordinary users cannot list or read your inbox. Mail bodies are not +included in public chat, notices, or the Wi-Fi administrator dashboard. Wi-Fi +room access uses local HTTP, so use a trusted Wi-Fi network. + +### Mail commands for regular users + +Use an encrypted CLI request to the room, or send the same command as a direct +message prefixed with `!mail` from a normal Companion app. These direct messages +are reserved mailbox commands and never become public room posts: + +~~~text +!mail mode private +!mail allow +!mail deny +!mail mode public +!mail mode closed +!mail delivery mailbox +!mail delivery chat +!mail settings +!mail settings 1 +!mail send dog lost +!mail check +!mail inbox +!mail list 1 +!mail read +!mail read +!mail ack +!mail delete +~~~ + +In a CLI-capable client, use `mail ...` without `!`. These commands affect only +the requesting identity. CLI list/settings replies use entry cursors; continue +at the returned `next=` while it is below `total=` or `allowed=`. List entries +show the immutable message ID, byte length and sender key; a short route may +show a sender prefix. Reads return readable text with `id`, `off`, `next` and +`total`; continue at `next` until it equals `total`. Text chunks end on a UTF-8 +character boundary. The route and command framing limit LoRa submissions to +less than the 512-byte storage limit; the browser can submit a full 512 bytes. + +Reading never removes a message. `ack` confirms receipt and durably removes it; +`delete` deliberately discards it. Both retain a bounded retry receipt. Exact +sender/request/body retries return the original ID, including after receipt or +reboot; conflicting reuse of that request ID is rejected. Each owner retains +up to eight submission receipts. Queued receipts stay retained; the oldest +retired receipt is evicted when needed. A very old retry after its retired +receipt has been evicted can enqueue again. + +`mail delivery mailbox` suppresses automatic public chat and topic delivery to +that identity, including retries and public backlog counts. It does not prevent +explicit public-board reads, change routes or erase chat. Returning to `chat` +resumes from the existing catch-up cursor. A copy already transmitted or queued +can still arrive while switching modes. Unreadable saved mailbox policy blocks +automatic chat rather than silently enabling it. + +### Sleeping tracker example + +A dog tracker can keep its mailbox private, approve its owner's sender identity, +and select mailbox-only delivery. The owner leaves `dog lost` while the tracker +is asleep. At its next scheduled check-in, the tracker authenticates to the +room, checks its inbox, retrieves that message in resumable chunks and confirms +receipt only after handling it. Lost replies and another sleep cycle do not +remove unread mail. Chat history and topics are not downloaded automatically. + +Room Server provides the room-side queue and polling protocol. A tracker still +needs client firmware that schedules check-ins and interprets authenticated +commands. Ordinary mailbox text is never executed as a CLI command, and does +not itself enable a tracker, wake it remotely or change its GPS reporting rate. + +### Wi-Fi mailbox management + +Open `/room`, join with your room credentials, and open **Personal mailbox**. +The page shows your complete browser mailbox address, public/private/closed +access, selected senders and mailbox-only delivery. It provides sending, paged +inbox metadata, selective body downloads, resumable reads, explicit receipt +confirmation and deletion. Unsaved drafts survive failed requests. The page +honors saved mailbox-only mode before its initial chat download; **Refresh +chat** remains an explicit choice. + +The browser has its own identity derived from its secret token and the server +identity. It cannot claim your radio's mailbox using a shared room password. +Mail sent to a radio key is retrieved by that radio; mail sent to the browser +address is retrieved using that browser token. Keep that token to retain access +to the same mailbox. Disconnecting clears downloaded private content from the +page's memory; the queued server mail remains. + +Administrators have a **Mailboxes** panel with paged owner metadata, queued +counts, access policy, selected senders, blocking, and confirmed deletion or +purge. Enter a full owner address to provision a radio's mailbox before it +checks in. Policy changes compare the loaded revision, including creation revision +zero; concurrent changes require a refresh. The dashboard does not download +other users' message bodies. Inbox and administrator index pages use snapshot +revisions to reject mixed pages after a concurrent change. + +### LoRa mailbox protocol + +Authenticated room users send encrypted `PAYLOAD_TYPE_REQ` requests. The usual +four-byte request tag precedes each payload and is echoed before the response. +All multi-byte integers are little-endian. Subtype `0x0b` is separate from chat +catch-up and board subtype `0x0a`. + +| Operation | Payload after tag | +| --- | --- | +| Settings/check | `0b 00` + expected revision `u32` + allow-list cursor `u8` | +| Inbox index | `0b 01` + expected revision `u32` + entry cursor `u8` | +| Read | `0b 02` + immutable message ID `u32` + offset `u16` | +| Send | `0b 03` + full recipient key (32 bytes) + body length `u16` + complete body | +| Confirm receipt | `0b 04` + message ID `u32` | +| Delete | `0b 05` + message ID `u32` | +| Allow sender | `0b 06` + expected revision `u32` + full sender key (32 bytes) | +| Deny sender | `0b 07` + expected revision `u32` + full sender key (32 bytes) | +| Access | `0b 08` + expected revision `u32` + mode `u8` (closed=0, public=1, private=2) | +| Delivery | `0b 09` + expected revision `u32` + mailbox-only `u8` (0 or 1) | + +Start index/settings at revision zero and cursor zero, then use the returned +revision on subsequent pages. `next=255` means complete. Sends are single +requests, without fragmented uploads; the complete body must fit the encrypted +request route. Read responses contain raw bytes in chunks of at most 128, reduced +as needed by the reply route. Continue by offset plus copied byte count. Reads +never acknowledge receipt implicitly. Logical requests allow only zero trailing +encryption padding. + +Responses begin with subtype, operation and status. Statuses are: `0` success, +`1` invalid, `2` storage unavailable, `3` write failure, `4` not found, `5` stale +revision, `6` forbidden, `7` full, `8` successful duplicate, `9` conflicting +request ID. Full response layouts are documented in +[RoomMailProtocol.h](https://github.com/mikecarper/MeshCore/blob/keymindCascade/src/helpers/RoomMailProtocol.h). +Mutations require space for their complete confirmation before writing. + ## Optional persistent chat history History uses RAM by default. From a local console or authenticated remote @@ -268,6 +425,8 @@ This management area offers: and send the corresponding Unix timestamp. - Assigning or removing saved access, blocking and unblocking full identities, editing the topic, enabling persistent history, and changing post/poll limits. +- Personal mailbox owner lists, policies, sender permissions and confirmed + message deletion or purge. Private mail bodies are not downloaded here. - Optional refresh every 15 seconds. Unsaved route edits remain in place while live status refreshes. diff --git a/docs/test-results/room_mailboxes.md b/docs/test-results/room_mailboxes.md new file mode 100644 index 000000000..d618933ff --- /dev/null +++ b/docs/test-results/room_mailboxes.md @@ -0,0 +1,77 @@ + + + + +# Personal room mailbox validation + +Validated on 2026-10-10 against the source changes in this commit. These checks +cover the room-side implementation, not a sleeping tracker client or a live +LoRa/Wi-Fi transfer. No lab radio was flashed for these mailbox checks. + +## Behavior exercised + +- One public room, separate personal mailboxes, and full authenticated owner + identities for policy, reads, receipt confirmation and deletion. +- Closed/public/private policies, eight selected senders, reader/writer roles, + caller and recipient bans, and permission revocation before cached replies. +- Mailbox-only suppression of public posts, topic delivery, active retries and + public unread counts. Corrupt saved policy blocks automatic chat. +- A later dog-tracker check-in retrieving `dog lost`, resumable reads, no + implicit acknowledgement, and an explicit durable receipt. Message text + never executes administrative commands. +- Up to 32 owners, 16 globally queued messages, four per owner, 512 UTF-8 bytes + per body and bounded retry receipts. Full queues preserve earlier mail. +- Exact retry, conflicting nonce reuse, retry after receipt/reboot, owner-only + delete and administrator purge. Very old retries are not guaranteed unique + after their retired receipt is evicted. +- Atomic bank/index publication, every tested interrupted-write boundary, + corruption, short writes, failed receipt saves and preserved recovery data. +- AES zero-padded LoRa framing, rejection of nonzero trailing data, bounded + route replies and rejection of oversized commands before mutation. +- Browser identity isolation, stale revision-zero creation, snapshot paging, + delayed responses after disconnect, drafts, safe text rendering, narrow + screens and administrator provisioning by full radio address. + +## Automated checks + +The mailbox store passed 3,978 scenarios on each of five filesystem API +variants (19,890 total), using AddressSanitizer, UndefinedBehaviorSanitizer and +OpenSSL SHA-256. The wire/CLI protocol passed 1,393 scenarios per variant +(6,965 total), using the firmware's actual Crypto SHA-256 implementation and +the same sanitizers. Adafruit file mocks reject default construction, matching +the actual nRF52 library. + +Production room transport tests passed 18 cases. Actual JSON handlers passed +seven existing base, six existing administrator and six new mailbox groups +with real ArduinoJson and stores. The delivery scheduler, room login, receive +admission, post permissions, HTTP ownership and stock app compatibility checks +passed after updating their production-code adapters. + +Chromium passed 17 existing room browser tests and nine mailbox tests, including +new radio mailbox provisioning without downloading another owner's body. +These checks are included in the unit-test workflow. + +## Firmware builds + +The following Room Server builds passed the normal flash and runtime RAM gates: + +- `heltec_v4_room_server` +- `RAK_4631_room_server` +- `t1000e_room_server` +- `RAK_WisMesh_Tag_room_server` +- `PicoW_room_server` + +The first nRF52 build exposed a non-default-constructible Adafruit file handle; +the implementation and fixtures were corrected before final builds passed. + +## Stack review + +The actual T1000-E ARM ELF retains the existing 8 KiB loop stack allocation. +The largest mailbox store frame is 1,320 bytes. Streamed policy reads avoid +loading the full receipt table in the delivery scheduler; write and readback +verification run sequentially rather than stacking both operations. + +A representative LoRa CLI send chain has 5,632 bytes of visible frames; the +binary request path has 4,352. These estimates exclude deeper filesystem, +library, virtual-call and task overhead, and are not proven worst-case bounds. +They do not replace a live long-duration stack-watermark test. diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 85e4e5b4b..5b4d1dc32 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ #include #include #include +#include #endif #include @@ -242,6 +244,94 @@ bool MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { return pushRoomTextToClient(client, post.post_timestamp, post.author, post.text); } +bool MyMesh::roomClientChatEnabled(ClientInfo* client) { + bool mailbox_only = false; + // An unreadable saved policy must never turn a private-only subscriber back + // into a public chat subscriber. Existing users without a mailbox keep chat. + return mesh::getRoomMailboxOnly(_fs, client->id.pub_key, mailbox_only) + == mesh::RoomMailResult::Success && !mailbox_only; +} + +bool MyMesh::handleRoomMailClientCommand(ClientInfo* client, const char* text, + uint64_t request_id, char* reply, size_t capacity) { + char command[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1]; + if (strlen(text) >= sizeof(command)) { + snprintf(reply, capacity, "Error command too long"); return true; + } + StrHelper::strncpy(command, text, sizeof(command)); + char prefix[4] = {}; + const char* body = mesh::normalizeRoomClientPathCommand(command, prefix); + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN + && !(role == PERM_ACL_GUEST && client->last_activity != 0)) return false; + if (prefix[0]) { + if (capacity < 4) return true; + memcpy(reply, prefix, 3); reply += 3; capacity -= 3; + } + const char* action = mesh::room_mail_protocol_detail::prefix(body, "mail") + ? mesh::room_mail_protocol_detail::spaces(body + 4) : ""; + const char* target_text = mesh::room_mail_protocol_detail::prefix(action, "send") + ? mesh::room_mail_protocol_detail::spaces(action + 4) : ""; + if (strlen(target_text) >= 64) { + char target[65]; memcpy(target, target_text, 64); target[64] = 0; + uint8_t key[32]; + if (mesh::room_mail_protocol_detail::key(target, key) + && !room_access.allowsIdentity(key)) { + snprintf(reply, capacity, "Err - recipient is blocked"); return true; + } + } + const bool can_send = role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN; + if (mesh::handleRoomMailCommand(_fs, client->id.pub_key, can_send, request_id, + body, reply, capacity, getRTCClock()->getCurrentTime())) return true; + if (strncmp(body, "get room.board", 14) == 0) { + return mesh::handleRoomBoardCommand(_fs, body, reply, capacity); + } + return false; +} + +bool MyMesh::handleRoomMailText(ClientInfo* client, mesh::Packet* packet, + const uint8_t* secret, uint8_t* data, size_t len) { + const char* text = reinterpret_cast(data + 5); + if (strncmp(text, "!mail", 5) != 0 || (text[5] != 0 && text[5] != ' ')) return false; + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN + && !(role == PERM_ACL_GUEST && client->last_activity != 0)) return true; + uint32_t timestamp; memcpy(×tamp, data, 4); + const uint32_t fingerprint = mesh::RemoteCliReplyCache::fingerprint(text, strlen(text)); + const char* cached = nullptr; + const bool retry = remote_cli_reply_cache.lookup(client->id.pub_key, timestamp, fingerprint, &cached); + if (!retry && timestamp <= client->last_timestamp) return true; + serviceRoomQuotas(); + if (!retry && !room_access.consumeKeepAlive(client->extra.room.poll_quota_used)) return true; + char reply[160] = {}; + const size_t route_capacity = mesh::clientACLReplyCapacity(packet->isRouteFlood(), packet->path_len); + const size_t capacity = route_capacity > 5 ? std::min(sizeof(reply), route_capacity - 5) : 0; + if (!capacity) return true; + if (retry) StrHelper::strncpy(reply, cached, capacity); + else { + client->last_timestamp = timestamp; + if (!handleRoomMailClientCommand(client, text + 1, timestamp, reply, capacity)) { + snprintf(reply, capacity, "Err - use !mail settings, mode, allow, deny, delivery, send, list, read, ack or delete"); + } + remote_cli_reply_cache.remember(client->id.pub_key, timestamp, fingerprint, reply); + } + client->last_activity = getRTCClock()->getCurrentTime(); + uint32_t ack_hash; + mesh::Utils::sha256(reinterpret_cast(&ack_hash), sizeof(ack_hash), data, + 5 + strlen(text), client->id.pub_key, PUB_KEY_SIZE); + auto ack = createAck(ack_hash); + if (ack) sendClientReply(client, ack, TXT_ACK_DELAY, packet->getPathHashSize()); + uint8_t output[165]; + const uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(output, &now, 4); output[4] = TXT_TYPE_PLAIN << 2; + const size_t length = strlen(reply); memcpy(output + 5, reply, length); + auto response = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, output, 5 + length); + if (response) sendClientReply(client, response, TXT_ACK_DELAY + REPLY_DELAY_MILLIS, + packet->getPathHashSize()); + (void)len; + return true; +} + #ifdef WITH_WEBCONFIG bool MyMesh::handleRoomWebCommand(const char* command, char* reply) { if (strcmp(command, "get room.web") == 0) { @@ -356,14 +446,20 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r error("admin permission required"); return; } const bool admin_read = strcmp(operation, "admin.users") == 0 - || strcmp(operation, "admin.user") == 0 || strcmp(operation, "admin.settings") == 0; + || strcmp(operation, "admin.user") == 0 || strcmp(operation, "admin.settings") == 0 + || strcmp(operation, "admin.mail.list") == 0 || strcmp(operation, "admin.mail.settings") == 0; const bool admin_write = strcmp(operation, "admin.route") == 0 || strcmp(operation, "admin.catchup") == 0 || strcmp(operation, "admin.access") == 0 || strcmp(operation, "admin.ban") == 0 || strcmp(operation, "admin.topic") == 0 - || strcmp(operation, "admin.history") == 0 || strcmp(operation, "admin.rates") == 0; + || strcmp(operation, "admin.history") == 0 || strcmp(operation, "admin.rates") == 0 + || strcmp(operation, "admin.mail.policy") == 0 || strcmp(operation, "admin.mail.delete") == 0; + const bool mail_read = strcmp(operation, "mail.settings") == 0 || strcmp(operation, "mail.check") == 0 + || strcmp(operation, "mail.list") == 0 || strcmp(operation, "mail.read") == 0; + const bool mail_write = strcmp(operation, "mail.policy") == 0 || strcmp(operation, "mail.send") == 0 + || strcmp(operation, "mail.ack") == 0 || strcmp(operation, "mail.delete") == 0; const bool writing = strcmp(operation, "post") == 0 || strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0 - || admin_write; + || admin_write || mail_write; if (writing && (!input["boot"].is() || input["boot"].as() != room_web_boot_id)) { error("radio restarted; refresh before sending"); return; @@ -376,7 +472,8 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r if (strcmp(operation, "status") != 0 && strcmp(operation, "posts") != 0 && strcmp(operation, "post") != 0 && strcmp(operation, "board.index") != 0 && strcmp(operation, "board.read") != 0 && strcmp(operation, "board.save") != 0 - && strcmp(operation, "board.delete") != 0 && !admin_read && !admin_write) { + && strcmp(operation, "board.delete") != 0 && !admin_read && !admin_write + && !mail_read && !mail_write) { error("unknown room operation"); return; } @@ -402,6 +499,10 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r } char key[65]; mesh::Utils::toHex(key, author.pub_key, PUB_KEY_SIZE); mesh::RoomJsonWriter json(response, capacity); + if (mesh::handleRoomMailWeb(_fs, author.pub_key, + role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN, role == PERM_ACL_ADMIN, + sequence, getRTCClock()->getCurrentTime(), input.as(), json, + [this](const uint8_t* identity) { return room_access.allowsIdentity(identity); })) return; if (admin_operation) { if (strcmp(operation, "admin.settings") == 0) { json.raw("{\"topic\":"); json.string(room_topic); @@ -545,12 +646,17 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r json.raw("{\"ok\":true}"); json.finish(); return; } if (strcmp(operation, "status") == 0) { + bool mailbox_only = false; + if (mesh::getRoomMailboxOnly(_fs, author.pub_key, mailbox_only) != mesh::RoomMailResult::Success) { + mailbox_only = true; + } json.raw("{\"name\":"); json.string(_prefs.node_name); json.raw(",\"topic\":"); json.string(room_topic); json.raw(",\"boot\":"); json.number(room_web_boot_id); json.raw(",\"role\":"); json.number(role); json.raw(",\"web_key\":"); json.string(key); json.raw(",\"persistent_history\":"); json.raw(room_history_enabled ? "true" : "false"); + json.raw(",\"mailbox_only\":"); json.raw(mailbox_only ? "true" : "false"); json.raw("}"); json.finish(); return; } if (strcmp(operation, "post") == 0) { @@ -959,6 +1065,7 @@ bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp, } uint8_t MyMesh::getUnsyncedCount(ClientInfo *client) { + if (!roomClientChatEnabled(client)) return 0; uint8_t count = 0; for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) { if (posts[k].post_timestamp > client->extra.room.sync_since // is new post for this Client? @@ -1043,6 +1150,18 @@ void MyMesh::serviceRoomPush() { for (int i = 0; i < acl.getNumClients(); ++i) { auto client = acl.getClientByIdx(i); + if (client->extra.room.pending_ack && !roomClientChatEnabled(client)) { + uint8_t retry_key[MAX_HASH_SIZE]; + mesh::roomDeliveryRetryKey(retry_key, sizeof(retry_key), self_id.pub_key, + client->id.pub_key, client->extra.room.pending_topic_revision != 0, + [](uint8_t* out, size_t size, const uint8_t* bytes, size_t length) { + mesh::Utils::sha256(out, size, bytes, length); + }); + cancelActiveMessageRetries(retry_key, client->extra.room.push_post_timestamp); + client->extra.room.pending_ack = 0; + client->extra.room.pending_topic_revision = 0; + continue; + } if (client->extra.room.pending_ack && millisHasNowPassed(client->extra.room.ack_timeout)) { const uint32_t topic_revision = client->extra.room.pending_topic_revision; @@ -1067,7 +1186,7 @@ void MyMesh::serviceRoomPush() { bool did_push = false; if (client->extra.room.pending_ack == 0 && client->last_activity != 0 && client->extra.room.push_failures < 3 - && room_access.allowsIdentity(client->id.pub_key)) { + && room_access.allowsIdentity(client->id.pub_key) && roomClientChatEnabled(client)) { const uint32_t now = getRTCClock()->getCurrentTime(); if (room_topic[0] != 0 && room_topic_ready && client->extra.room.topic_seen_revision != room_topic_revision @@ -1128,6 +1247,21 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t // uint32_t now = getRTCClock()->getCurrentTimeUnique(); // memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp memcpy(reply_data, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag') + if (payload[0] == mesh::ROOM_MAIL_REQUEST_SUBTYPE) { + const uint8_t role = sender->permissions & PERM_ACL_ROLE_MASK; + if (role != PERM_ACL_READ_ONLY && role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN + && !(role == PERM_ACL_GUEST && sender->last_activity != 0)) return 0; + if (payload_len >= 34 && payload[1] == 3 && !room_access.allowsIdentity(payload + 2)) { + const size_t length = mesh::room_mail_protocol_detail::header(payload[1], + mesh::RoomMailResult::Forbidden, reply_data + 4, reply_capacity - 4); + return length ? 4 + length : 0; + } + const size_t length = mesh::handleRoomMailRequest(_fs, sender->id.pub_key, + role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN, sender_timestamp, + payload, payload_len, reply_data + 4, reply_capacity - 4, + getRTCClock()->getCurrentTime()); + return length ? 4 + length : 0; + } if (payload[0] == mesh::ROOM_BOARD_REQUEST_SUBTYPE) { const size_t length = mesh::handleRoomBoardRequest(_fs, payload, payload_len, reply_data + 4, reply_capacity - 4); @@ -1807,6 +1941,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, data[len] = 0; // need to make a C string again, with null terminator const char* text = (const char*)&data[5]; const size_t text_len = strlen(text); + if (flags == TXT_TYPE_PLAIN && handleRoomMailText(client, packet, secret, data, len)) return; uint8_t temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1]; temp[5] = 0; @@ -1865,7 +2000,9 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; const bool own_path_allowed = (mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None - || strcmp(body, "get room.catchup") == 0 || strncmp(body, "room.catchup ", 13) == 0) + || strcmp(body, "get room.catchup") == 0 || strncmp(body, "room.catchup ", 13) == 0 + || strncmp(body, "mail ", 5) == 0 || strcmp(body, "mail") == 0 + || strncmp(body, "get room.board", 14) == 0) && (role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && client->last_activity != 0)); @@ -1886,7 +2023,9 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, return; } else { if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5]) - && !handleRoomCatchUpCommand(client, (char*)text, (char*)&temp[5])) { + && !handleRoomCatchUpCommand(client, (char*)text, (char*)&temp[5]) + && !handleRoomMailClientCommand(client, text, request_id, (char*)&temp[5], + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1)) { if (client->isAdmin()) { handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], i, packet->getPathHashSize()); @@ -3532,6 +3671,25 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply mesh::cli::normalizeCommandVerb(command); + if (mesh::room_mail_protocol_detail::prefix(command, "mail")) { + uint64_t mail_request_id = sender_timestamp; + if (!mail_request_id) { + getRNG()->random(reinterpret_cast(&mail_request_id), sizeof(mail_request_id)); + if (!mail_request_id) { strcpy(reply, "Err - mail request ID unavailable"); return; } + } + const char* mail_action = mesh::room_mail_protocol_detail::spaces(command + 4); + const char* mail_target = mesh::room_mail_protocol_detail::prefix(mail_action, "send") + ? mesh::room_mail_protocol_detail::spaces(mail_action + 4) : ""; + if (strlen(mail_target) >= 64) { + char target[65]; memcpy(target, mail_target, 64); target[64] = 0; + uint8_t key[32]; + if (mesh::room_mail_protocol_detail::key(target, key) && !room_access.allowsIdentity(key)) { + strcpy(reply, "Err - recipient is blocked"); return; + } + } + if (mesh::handleRoomMailCommand(_fs, self_id.pub_key, true, mail_request_id, + command, reply, 157, getRTCClock()->getCurrentTime())) return; + } if (handleRoomManagementCommand(command, reply)) return; if (handleRoomTopicCommand(command, reply)) return; if (handleRoomHistoryCommand(command, reply)) return; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index def6ed166..9fadd5f96 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -602,6 +602,11 @@ public: int gpio_client_index = -1, uint8_t gpio_path_hash_size = 1); bool handleClientPathCommand(ClientInfo* client, char* command, char* reply); + bool roomClientChatEnabled(ClientInfo* client); + bool handleRoomMailClientCommand(ClientInfo* client, const char* text, uint64_t request_id, + char* reply, size_t capacity); + bool handleRoomMailText(ClientInfo* client, mesh::Packet* packet, const uint8_t* secret, + uint8_t* data, size_t len); bool executeClientPathCommand(ClientInfo* client, mesh::RoomClientPathCommand operation, const char* body, char* reply); bool setRoomClientPath(ClientInfo* client, const char* which, const char* value, char* reply); diff --git a/src/helpers/RoomMailProtocol.h b/src/helpers/RoomMailProtocol.h new file mode 100644 index 000000000..c10ce76e0 --- /dev/null +++ b/src/helpers/RoomMailProtocol.h @@ -0,0 +1,443 @@ +#pragma once + +#include +#include +#include +#include +#include "RoomMailStore.h" + +namespace mesh { + +static constexpr uint8_t ROOM_MAIL_REQUEST_SUBTYPE = 0x0b; +static constexpr size_t ROOM_MAIL_PROTOCOL_READ_CHUNK = 128; +static constexpr size_t ROOM_MAIL_MAX_COMMAND_LENGTH = 600; + +// Requests follow the existing encrypted room request tag. The trusted caller +// supplies the authenticated full sender key, send permission and durable nonce; +// requests never supply an inbox owner. All integers below are little-endian. +// Every response starts [0x0b, operation, status]. Status values follow +// RoomMailResult: 0 success, 1 invalid, 2 unavailable, 3 write failure, +// 4 not found, 5 stale revision, 6 forbidden, 7 full, 8 successful duplicate, +// 9 nonce/body mismatch. A short invalid response may contain only this header. +// +// op request after [0x0b, op] response after common header +// 0 expected revision u32, allow cursor u8 revision u32, mode u8, +// mailbox-only u8, allow-count u8, +// start u8, next u8, queued u8, +// zero or more full 32-byte keys +// 1 expected revision u32, cursor u8 revision u32, total u8, start u8, +// next u8, copied u8, entries: +// id u32, sender key[32], length u16 +// 2 message id u32, offset u16 id u32, offset u16, total u16, +// copied u8, sender key[32], bytes +// 3 recipient key[32], body length u16, message id u32 +// complete UTF-8 body bytes +// 4 message id u32 (acknowledge receipt) message id u32 +// 5 message id u32 (delete) message id u32 +// 6 expected revision u32, allowed key[32] new revision u32 +// 7 expected revision u32, denied key[32] new revision u32 +// 8 expected revision u32, mode u8 new revision u32 +// 9 expected revision u32, mailbox-only u8 new revision u32 +// +// Mode values: 0 closed, 1 public, 2 private (allow list). All queue entries are +// unacknowledged; ACK durably removes a message, preserving its retry receipt. +// Index responses contain at most two whole entries. next==255 means complete. +// Start an inbox index at revision=0,cursor=0, then retain its returned revision. +// Settings are private to this owner; their index uses the same revision rules. +// Policy changes must echo the current revision, including 0 for a new inbox. +// Message IDs identify immutable content; reads can resume by offset. Empty +// inboxes are closed by default and reads/checks never provision an inbox. +// Every mutating operation checks its complete response fits BEFORE writing. +// Bodies are not fragmented on upload: each send must fit its request route. +// Decrypted room packets include AES zero padding. Logical request lengths are +// determined by the operation (and send's declared body length); only zero bytes +// may follow the logical request. Padding never becomes part of a stored body. + +namespace room_mail_protocol_detail { + +inline uint16_t get16(const uint8_t* bytes) { + return uint16_t(bytes[0]) | (uint16_t(bytes[1]) << 8); +} +inline uint32_t get32(const uint8_t* bytes) { + return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8) + | (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24); +} +inline void put16(uint8_t* bytes, uint16_t value) { + bytes[0] = uint8_t(value); bytes[1] = uint8_t(value >> 8); +} +inline void put32(uint8_t* bytes, uint32_t value) { + for (uint8_t i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i)); +} +inline uint8_t status(RoomMailResult result) { + switch (result) { + case RoomMailResult::Success: return 0; + case RoomMailResult::Invalid: return 1; + case RoomMailResult::Unavailable: return 2; + case RoomMailResult::WriteFailure: return 3; + case RoomMailResult::NotFound: return 4; + case RoomMailResult::StaleVersion: return 5; + case RoomMailResult::Forbidden: return 6; + case RoomMailResult::Full: return 7; + case RoomMailResult::Duplicate: return 8; + case RoomMailResult::Mismatch: return 9; + } + return 1; +} +inline size_t header(uint8_t operation, RoomMailResult result, + uint8_t* reply, size_t capacity) { + if (!reply || capacity < 3) return 0; + reply[0] = ROOM_MAIL_REQUEST_SUBTYPE; reply[1] = operation; + reply[2] = status(result); return 3; +} +inline bool successful(RoomMailResult result) { + return result == RoomMailResult::Success || result == RoomMailResult::Duplicate; +} +inline bool validKey(const uint8_t* key) { + if (!key) return false; + uint8_t nonzero = 0; + for (size_t i = 0; i < 32; ++i) nonzero |= key[i]; + return nonzero != 0; +} +inline bool paddedLength(const uint8_t* payload, size_t length, size_t logical) { + if (!payload || length < logical) return false; + for (size_t i = logical; i < length; ++i) if (payload[i] != 0) return false; + return true; +} +inline bool key(const char* text, uint8_t (&out)[32]) { + if (!text || strlen(text) != 64) return false; + uint8_t parsed[32]; + for (size_t i = 0; i < 32; ++i) { + unsigned value = 0; + for (size_t j = 0; j < 2; ++j) { + const char c = text[i * 2 + j]; unsigned digit; + if (c >= '0' && c <= '9') digit = unsigned(c - '0'); + else if (c >= 'a' && c <= 'f') digit = unsigned(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') digit = unsigned(c - 'A' + 10); + else return false; + value = (value << 4) | digit; + } + parsed[i] = uint8_t(value); + } + if (!validKey(parsed)) return false; + memcpy(out, parsed, 32); return true; +} +inline void hex(const uint8_t* bytes, char (&out)[65]) { + static const char alphabet[] = "0123456789abcdef"; + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = alphabet[bytes[i] >> 4]; out[i * 2 + 1] = alphabet[bytes[i] & 15]; + } + out[64] = 0; +} +inline const char* modeName(RoomMailMode mode) { + return mode == RoomMailMode::Public ? "public" : mode == RoomMailMode::Private ? "private" : "closed"; +} +inline const char* spaces(const char* cursor) { while (*cursor == ' ') ++cursor; return cursor; } +inline bool prefix(const char* command, const char* expected) { + const size_t length = strlen(expected); + return strncmp(command, expected, length) == 0 && (command[length] == 0 || command[length] == ' '); +} +inline bool number(const char*& cursor, uint32_t maximum, uint32_t& value) { + cursor = spaces(cursor); value = 0; + if (*cursor < '0' || *cursor > '9') return false; + do { + const uint8_t digit = uint8_t(*cursor++ - '0'); + if (value > maximum / 10 || (value == maximum / 10 && digit > maximum % 10)) return false; + value = value * 10 + digit; + } while (*cursor >= '0' && *cursor <= '9'); + return *cursor == 0 || *cursor == ' '; +} +inline size_t utf8Prefix(const uint8_t* text, size_t length) { + size_t cursor = 0; + while (cursor < length) { + const uint8_t first = text[cursor]; + const size_t width = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4; + if (width > length - cursor) break; + cursor += width; + } + return cursor; +} +inline void textResult(char* reply, size_t capacity, RoomMailResult result, + uint32_t id = 0, bool message = false) { + if (!reply || !capacity) return; + if (successful(result)) { + if (message) snprintf(reply, capacity, "OK id=%lu%s", (unsigned long)id, + result == RoomMailResult::Duplicate ? " duplicate" : ""); + else snprintf(reply, capacity, "OK"); + return; + } + const char* error = "invalid"; + switch (result) { + case RoomMailResult::Unavailable: error = "unavailable"; break; + case RoomMailResult::WriteFailure: error = "write failure"; break; + case RoomMailResult::NotFound: error = "not found"; break; + case RoomMailResult::StaleVersion: error = "stale revision"; break; + case RoomMailResult::Forbidden: error = "permission denied"; break; + case RoomMailResult::Full: error = "mailbox full"; break; + case RoomMailResult::Mismatch: error = "request changed"; break; + default: break; + } + snprintf(reply, capacity, "Error %s", error); +} +template +RoomMailResult ownStatus(Filesystem* fs, const uint8_t* owner, RoomMailStatus& value) { + const RoomMailResult result = getRoomMailStatus(fs, owner, value); + return result == RoomMailResult::NotFound ? RoomMailResult::Success : result; +} +inline RoomMailResult editSettings(RoomMailSettings& settings, uint8_t operation, + const uint8_t* value) { + if (operation == 8) { + if (*value > 2) return RoomMailResult::Invalid; + settings.mode = static_cast(*value); return RoomMailResult::Success; + } + if (operation == 9) { + if (*value > 1) return RoomMailResult::Invalid; + settings.mailbox_only = *value != 0; return RoomMailResult::Success; + } + if (!validKey(value)) return RoomMailResult::Invalid; + uint8_t position = 0; + while (position < settings.allowed_count && memcmp(settings.allowed[position], value, 32) != 0) ++position; + if (operation == 6) { + if (position < settings.allowed_count) return RoomMailResult::Success; + if (settings.allowed_count >= 8) return RoomMailResult::Full; + memcpy(settings.allowed[settings.allowed_count++], value, 32); + } else { + if (position == settings.allowed_count) return RoomMailResult::Success; + for (uint8_t i = position; i + 1 < settings.allowed_count; ++i) + memcpy(settings.allowed[i], settings.allowed[i + 1], 32); + memset(settings.allowed[--settings.allowed_count], 0, 32); + } + return RoomMailResult::Success; +} +} // namespace room_mail_protocol_detail + +template +size_t handleRoomMailRequest(Filesystem* fs, const uint8_t* owner, bool can_send, + uint64_t request_id, const uint8_t* payload, size_t length, + uint8_t* reply, size_t capacity, uint32_t created = 0) { + using namespace room_mail_protocol_detail; + const uint8_t operation = payload && length > 1 ? payload[1] : 255; + if (!payload || length < 2 || payload[0] != ROOM_MAIL_REQUEST_SUBTYPE || operation > 9 + || !validKey(owner) || !reply || capacity < 3) + return header(operation, RoomMailResult::Invalid, reply, capacity); + RoomMailResult result = RoomMailResult::Invalid; + if (operation == 0 || operation == 1) { + if (!paddedLength(payload, length, 7) || capacity < (operation == 0 ? 13U : 11U)) + return header(operation, result, reply, capacity); + RoomMailStatus state; + result = ownStatus(fs, owner, state); + if (result != RoomMailResult::Success) return header(operation, result, reply, capacity); + const uint32_t expected = get32(payload + 2); const uint8_t start = payload[6]; + if ((expected && expected != state.revision) || (!expected && start)) + return header(operation, RoomMailResult::StaleVersion, reply, capacity); + header(operation, result, reply, capacity); put32(reply + 3, state.revision); + if (operation == 0) { + if (start > state.settings.allowed_count) return header(operation, RoomMailResult::Invalid, reply, capacity); + reply[7] = static_cast(state.settings.mode); reply[8] = state.settings.mailbox_only ? 1 : 0; + reply[9] = state.settings.allowed_count; reply[10] = start; reply[12] = state.count; + size_t used = 13; uint8_t next = start; + while (next < state.settings.allowed_count && capacity - used >= 32) { + memcpy(reply + used, state.settings.allowed[next++], 32); used += 32; + } + if (next == start && next < state.settings.allowed_count) + return header(operation, RoomMailResult::Invalid, reply, capacity); + reply[11] = next < state.settings.allowed_count ? next : 255; return used; + } + if (start > state.count) return header(operation, RoomMailResult::Invalid, reply, capacity); + const size_t room = (capacity - 11) / 38; + if (!room && start < state.count) return header(operation, RoomMailResult::Invalid, reply, capacity); + RoomMailMessage messages[2]; size_t copied = 0; + if (state.count) result = listRoomMail(fs, owner, state.revision, start, messages, room < 2 ? room : 2, copied); + if (result != RoomMailResult::Success) return header(operation, result, reply, capacity); + reply[7] = state.count; reply[8] = start; reply[9] = start + copied < state.count ? uint8_t(start + copied) : 255; + reply[10] = uint8_t(copied); size_t used = 11; + for (size_t i = 0; i < copied; ++i) { + put32(reply + used, messages[i].id); memcpy(reply + used + 4, messages[i].sender, 32); + put16(reply + used + 36, messages[i].length); used += 38; + } + return used; + } + if (operation == 2) { + if (!paddedLength(payload, length, 8) || capacity < 44) return header(operation, result, reply, capacity); + const uint32_t id = get32(payload + 2); const uint16_t offset = get16(payload + 6); + const size_t available = capacity - 44; + uint8_t bytes[ROOM_MAIL_PROTOCOL_READ_CHUNK]; size_t copied = 0; RoomMailMessage message; + // A zero-byte EOF still requires a nonzero read capacity in the store. + result = readRoomMail(fs, owner, id, offset, bytes, + available ? (available < sizeof(bytes) ? available : sizeof(bytes)) : 1, copied, message); + if (result != RoomMailResult::Success) return header(operation, result, reply, capacity); + if (copied > available) return header(operation, RoomMailResult::Invalid, reply, capacity); + header(operation, result, reply, capacity); put32(reply + 3, id); put16(reply + 7, offset); + put16(reply + 9, message.length); reply[11] = uint8_t(copied); memcpy(reply + 12, message.sender, 32); + if (copied) memcpy(reply + 44, bytes, copied); + return 44 + copied; + } + if (capacity < 7) return header(operation, result, reply, capacity); + uint32_t id = 0; + if (operation == 3) { + if (length < 36 || !validKey(payload + 2)) + return header(operation, result, reply, capacity); + const size_t body_length = get16(payload + 34); + if (!paddedLength(payload, length, 36 + body_length)) return header(operation, result, reply, capacity); + result = can_send ? sendRoomMail(fs, owner, payload + 2, request_id, + reinterpret_cast(payload + 36), body_length, id, created) : RoomMailResult::Forbidden; + } else if (operation == 4 || operation == 5) { + if (!paddedLength(payload, length, 6) || !(id = get32(payload + 2))) return header(operation, result, reply, capacity); + result = operation == 4 ? acknowledgeRoomMail(fs, owner, id) : deleteRoomMail(fs, owner, id); + } else { + if (!paddedLength(payload, length, operation <= 7 ? 38U : 7U)) return header(operation, result, reply, capacity); + RoomMailStatus state; result = ownStatus(fs, owner, state); + if (result != RoomMailResult::Success) return header(operation, result, reply, capacity); + if (get32(payload + 2) != state.revision) return header(operation, RoomMailResult::StaleVersion, reply, capacity); + result = editSettings(state.settings, operation, payload + 6); + if (result == RoomMailResult::Success) result = saveRoomMailSettings(fs, owner, state.settings, state.revision); + if (result == RoomMailResult::Success) { + result = ownStatus(fs, owner, state); id = state.revision; + } + } + header(operation, result, reply, capacity); put32(reply + 3, id); return 7; +} + +// The caller strips an optional ! prefix for the stock-app !mail fallback. +// Only the trusted owner is used for settings/list/read/ack/delete. Ordinary +// authenticated readers may manage their own inbox; sends additionally require +// can_send. CLI reads return complete UTF-8 characters with the next byte offset. +// An offset in the middle of a character is rejected. Mutating replies +// require capacity >=32 so their ID/confirmation cannot be silently truncated. +// CLI next==total (or allowed count) completes an index. A short route may show +// a sender key prefix followed by ".."; only complete keys authorize/send mail. +template +bool handleRoomMailCommand(Filesystem* fs, const uint8_t* owner, bool can_send, + uint64_t request_id, const char* command, + char* reply, size_t capacity, uint32_t created = 0) { + using namespace room_mail_protocol_detail; + if (!command || !prefix(command, "mail")) return false; + if (!reply || !capacity) return true; + reply[0] = 0; + size_t length = 0; + while (length <= ROOM_MAIL_MAX_COMMAND_LENGTH && command[length]) ++length; + if (length > ROOM_MAIL_MAX_COMMAND_LENGTH || !validKey(owner)) { + textResult(reply, capacity, RoomMailResult::Invalid); return true; + } + const char* body = spaces(command + 4); + const bool settings = prefix(body, "settings") || prefix(body, "check"); + const bool inbox = prefix(body, "inbox"); + const bool list = prefix(body, "list") || inbox, read = prefix(body, "read"); + const bool send = prefix(body, "send"), ack = prefix(body, "ack"), del = prefix(body, "delete"); + const bool mode = prefix(body, "mode"), delivery = prefix(body, "delivery"); + const bool allow = prefix(body, "allow"), deny = prefix(body, "deny"); + if (!(settings || list || read || send || ack || del || mode || delivery || allow || deny)) { + textResult(reply, capacity, RoomMailResult::Invalid); return true; + } + if (!(settings || list || read) && capacity < 32) { + textResult(reply, capacity, RoomMailResult::Invalid); return true; + } + RoomMailResult result = RoomMailResult::Invalid; + if (send) { + const char* cursor = spaces(body + 4); const char* end = strchr(cursor, ' '); + uint8_t recipient[32]; char text_key[65]; + if (!end || end - cursor != 64) { textResult(reply, capacity, result); return true; } + memcpy(text_key, cursor, 64); text_key[64] = 0; + const char* text = spaces(end); + uint32_t id = 0; + if (key(text_key, recipient) && *text) + result = can_send ? sendRoomMail(fs, owner, recipient, request_id, text, strlen(text), id, created) : RoomMailResult::Forbidden; + textResult(reply, capacity, result, id, true); return true; + } + if (ack || del || read) { + const char* cursor = body + (ack ? 3 : del ? 6 : 4); uint32_t id, offset = 0; + if (!number(cursor, UINT32_MAX, id) || id == 0 + || (read && *spaces(cursor) && !number(cursor, 512, offset)) || *spaces(cursor)) { + textResult(reply, capacity, result); return true; + } + if (!read) { + result = ack ? acknowledgeRoomMail(fs, owner, id) : deleteRoomMail(fs, owner, id); + textResult(reply, capacity, result, id, true); return true; + } + RoomMailMessage message; uint8_t bytes[ROOM_MAIL_PROTOCOL_READ_CHUNK]; size_t copied = 0; + result = readRoomMail(fs, owner, id, offset, bytes, sizeof(bytes), copied, message); + if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; } + if (copied && (bytes[0] & 0xc0) == 0x80) { + snprintf(reply, capacity, "Error offset inside UTF-8 character"); return true; + } + size_t count = utf8Prefix(bytes, copied); int prefix_length; + do { + prefix_length = snprintf(reply, capacity, "id=%lu off=%lu next=%lu total=%u text=", + (unsigned long)id, (unsigned long)offset, (unsigned long)(offset + count), unsigned(message.length)); + if (prefix_length >= 0 && size_t(prefix_length) + count < capacity) break; + if (!count) { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + count = utf8Prefix(bytes, count - 1); + } while (true); + if (!count && copied) { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + memcpy(reply + prefix_length, bytes, count); reply[prefix_length + count] = 0; return true; + } + RoomMailStatus state; result = ownStatus(fs, owner, state); + if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; } + if (settings || list) { + const char* cursor = body + (settings ? (prefix(body, "check") ? 5 : 8) : inbox ? 5 : 4); uint32_t start = 0; + const uint8_t total = settings ? state.settings.allowed_count : state.count; + if ((*spaces(cursor) && !number(cursor, total, start)) || *spaces(cursor)) { + textResult(reply, capacity, RoomMailResult::Invalid); return true; + } + char entries[160] = {}; size_t used = 0; uint32_t next = start; int prefix_length; + RoomMailMessage messages[2]; size_t copied = 0; + if (list && state.count) result = listRoomMail(fs, owner, state.revision, start, messages, 2, copied); + if (result != RoomMailResult::Success) { textResult(reply, capacity, result); return true; } + while (next < total && (settings || next - start < copied)) { + char full_key[65]; char entry[90]; + if (settings) { hex(state.settings.allowed[next], full_key); snprintf(entry, sizeof(entry), " %s", full_key); } + else { + const auto& message = messages[next - start]; hex(message.sender, full_key); + snprintf(entry, sizeof(entry), " %lu/%u:%s", (unsigned long)message.id, unsigned(message.length), full_key); + } + char header_text[128]; + const int n = settings ? snprintf(header_text, sizeof(header_text), + "rev=%lu mode=%s delivery=%s queued=%u allowed=%u start=%lu next=%lu", + (unsigned long)state.revision, modeName(state.settings.mode), state.settings.mailbox_only ? "mailbox" : "chat", + unsigned(state.count), unsigned(total), (unsigned long)start, (unsigned long)(next + 1)) + : snprintf(header_text, sizeof(header_text), "rev=%lu total=%u start=%lu next=%lu", + (unsigned long)state.revision, unsigned(total), (unsigned long)start, (unsigned long)(next + 1)); + if (!settings && n >= 0 && size_t(n) + used + strlen(entry) >= capacity) { + const auto& message = messages[next - start]; + for (size_t digits = 16; digits >= 8; digits -= 8) { + full_key[digits] = 0; + snprintf(entry, sizeof(entry), " %lu/%u:%s..", (unsigned long)message.id, unsigned(message.length), full_key); + if (size_t(n) + used + strlen(entry) < capacity) break; + } + } + const size_t entry_length = strlen(entry); + if (n < 0 || size_t(n) >= sizeof(header_text) || entry_length >= sizeof(entries) - used + || size_t(n) + used + entry_length >= capacity) break; + memcpy(entries + used, entry, entry_length + 1); used += entry_length; ++next; + } + if (next == start && next < total) { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + prefix_length = settings ? snprintf(reply, capacity, + "rev=%lu mode=%s delivery=%s queued=%u allowed=%u start=%lu next=%lu%s", + (unsigned long)state.revision, modeName(state.settings.mode), state.settings.mailbox_only ? "mailbox" : "chat", + unsigned(state.count), unsigned(total), (unsigned long)start, (unsigned long)next, entries) + : snprintf(reply, capacity, "rev=%lu total=%u start=%lu next=%lu%s", (unsigned long)state.revision, + unsigned(total), (unsigned long)start, (unsigned long)next, entries); + if (prefix_length < 0 || size_t(prefix_length) >= capacity) textResult(reply, capacity, RoomMailResult::Invalid); + return true; + } + const char* value = spaces(body + (mode ? 4 : delivery ? 8 : allow ? 5 : 4)); + uint8_t bytes[32] = {}; uint8_t operation; + if (mode) { + operation = 8; + if (strcmp(value, "closed") == 0) bytes[0] = 0; + else if (strcmp(value, "public") == 0) bytes[0] = 1; + else if (strcmp(value, "private") == 0) bytes[0] = 2; + else { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + } else if (delivery) { + operation = 9; + if (strcmp(value, "mailbox") == 0) bytes[0] = 1; + else if (strcmp(value, "chat") != 0) { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + } else { + operation = allow ? 6 : 7; + if (!key(value, bytes)) { textResult(reply, capacity, RoomMailResult::Invalid); return true; } + } + result = editSettings(state.settings, operation, bytes); + if (result == RoomMailResult::Success) result = saveRoomMailSettings(fs, owner, state.settings, state.revision); + textResult(reply, capacity, result); return true; +} + +} // namespace mesh diff --git a/src/helpers/RoomMailStore.h b/src/helpers/RoomMailStore.h new file mode 100644 index 000000000..ff85435c7 --- /dev/null +++ b/src/helpers/RoomMailStore.h @@ -0,0 +1,729 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "FilePresence.h" + +namespace mesh { + +static constexpr uint8_t ROOM_MAIL_MAX_OWNERS = 32; +static constexpr uint8_t ROOM_MAIL_MAX_MESSAGES = 16; +static constexpr uint8_t ROOM_MAIL_MAX_PER_OWNER = 4; +static constexpr uint8_t ROOM_MAIL_MAX_ALLOWED = 8; +static constexpr uint8_t ROOM_MAIL_RETRY_RECEIPTS = 8; +static constexpr size_t ROOM_MAIL_MAX_BODY_LENGTH = 512; +static constexpr size_t ROOM_MAIL_READ_MAX_CHUNK = 128; +static const char ROOM_MAIL_PRIMARY_PATH[] = "/room_mail"; +static const char ROOM_MAIL_TEMP_PATH[] = "/room_mail.tmp"; +static const char ROOM_MAIL_BACKUP_PATH[] = "/room_mail.bak"; + +enum class RoomMailResult : uint8_t { + Success, Invalid, Unavailable, WriteFailure, NotFound, StaleVersion, + Forbidden, Full, Duplicate, Mismatch +}; +enum class RoomMailMode : uint8_t { Closed = 0, Public = 1, Private = 2 }; +struct RoomMailSettings { + RoomMailMode mode = RoomMailMode::Closed; + bool mailbox_only = false; + uint8_t allowed_count = 0; + uint8_t allowed[ROOM_MAIL_MAX_ALLOWED][32] = {}; +}; +struct RoomMailStatus { + uint32_t revision = 0; + uint8_t count = 0; + RoomMailSettings settings; +}; +struct RoomMailMessage { + uint32_t id = 0; + uint64_t request_id = 0; + uint8_t sender[32] = {}; + uint16_t length = 0; + uint32_t created = 0; +}; +struct RoomMailStats { + uint32_t revision = 0; + uint8_t owners = 0; + uint8_t messages = 0; +}; + +// The caller supplies the authenticated full identity for owner operations. +// Public means eligible room writers; room roles and bans remain the caller's +// responsibility. The room operator is trusted: bodies are plaintext at rest. +inline bool roomMailSenderAllowed(const RoomMailSettings& settings, + const uint8_t* owner, const uint8_t* sender) { + if (!owner || !sender) return false; + if (memcmp(owner, sender, 32) == 0) return true; + if (settings.mode == RoomMailMode::Public) return true; + if (settings.mode != RoomMailMode::Private) return false; + for (uint8_t i = 0; i < settings.allowed_count && i < ROOM_MAIL_MAX_ALLOWED; ++i) + if (memcmp(settings.allowed[i], sender, 32) == 0) return true; + return false; +} + +namespace room_mail_detail { +static constexpr size_t INDEX_HEADER_SIZE = 16; +static constexpr size_t INDEX_ENTRY_SIZE = 36; +static constexpr size_t OWNER_HEADER_SIZE = 48; +static constexpr size_t POLICY_SIZE = 260; +static constexpr size_t RECORD_SIZE = 88; +static constexpr size_t BODY_START = OWNER_HEADER_SIZE + POLICY_SIZE + + ROOM_MAIL_RETRY_RECEIPTS * RECORD_SIZE; +static constexpr size_t STREAM_CHUNK = 64; +static const uint8_t INDEX_MAGIC[4] = {'R', 'M', 'I', 1}; +static const uint8_t OWNER_MAGIC[4] = {'R', 'M', 'O', 1}; +enum : uint8_t { Queued = 1, Acknowledged = 2, Deleted = 3 }; +struct Entry { + uint8_t owner[32] = {}; + uint8_t slot = 0, bank = 0, count = 0; +}; +struct Index { + uint32_t revision = 0, slots = 0; + uint8_t count = 0, messages = 0; +}; +struct Record { + RoomMailMessage message; + uint8_t digest[32] = {}; + uint16_t offset = 0; + uint8_t state = 0; +}; +struct Owner { + RoomMailSettings settings; + uint32_t revision = 0; + uint8_t count = 0, receipts = 0; + Record records[ROOM_MAIL_RETRY_RECEIPTS]; +}; +static_assert(sizeof(Owner) <= 1152, "Mailbox working metadata must remain bounded"); + +inline void clearSettings(RoomMailSettings& settings) { + settings.mode = RoomMailMode::Closed; + settings.mailbox_only = false; settings.allowed_count = 0; + memset(settings.allowed, 0, sizeof(settings.allowed)); +} +inline void clearStatus(RoomMailStatus& status) { + status.revision = 0; status.count = 0; clearSettings(status.settings); +} + +// Avoid an aggregate Owner{} assignment: some size-optimized embedded builds +// reserve a second full Owner temporary on the stack even for the error path. +inline void clearOwner(Owner& owner) { + owner.revision = 0; owner.count = 0; owner.receipts = 0; + clearSettings(owner.settings); + for (uint8_t i = 0; i < ROOM_MAIL_RETRY_RECEIPTS; ++i) { + Record& record = owner.records[i]; + record.message.id = 0; record.message.request_id = 0; + record.message.length = 0; record.message.created = 0; + memset(record.message.sender, 0, sizeof(record.message.sender)); + memset(record.digest, 0, sizeof(record.digest)); + record.offset = 0; record.state = 0; + } +} + +inline bool nonzero(const uint8_t* key) { + if (!key) return false; + for (uint8_t i = 0; i < 32; ++i) if (key[i]) return true; + return false; +} +inline uint32_t get32(const uint8_t* p) { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} +inline void put32(uint8_t* p, uint32_t value) { + for (uint8_t i = 0; i < 4; ++i) p[i] = uint8_t(value >> (8 * i)); +} +inline uint16_t get16(const uint8_t* p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); } +inline void put16(uint8_t* p, uint16_t value) { p[0] = uint8_t(value); p[1] = uint8_t(value >> 8); } +inline uint64_t get64(const uint8_t* p) { + return uint64_t(get32(p)) | (uint64_t(get32(p + 4)) << 32); +} +inline void put64(uint8_t* p, uint64_t value) { + put32(p, uint32_t(value)); put32(p + 4, uint32_t(value >> 32)); +} +inline uint32_t crc(uint32_t value, const uint8_t* bytes, size_t length) { + while (length--) { + value ^= *bytes++; + for (uint8_t bit = 0; bit < 8; ++bit) + value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0); + } + return value; +} +inline bool zero(const uint8_t* bytes, size_t length) { + while (length--) if (*bytes++) return false; + return true; +} +inline bool validSettings(const RoomMailSettings& s) { + if (uint8_t(s.mode) > 2 || s.allowed_count > ROOM_MAIL_MAX_ALLOWED) return false; + for (uint8_t i = 0; i < s.allowed_count; ++i) { + if (!nonzero(s.allowed[i])) return false; + for (uint8_t j = 0; j < i; ++j) if (!memcmp(s.allowed[i], s.allowed[j], 32)) return false; + } + return true; +} +// Complete UTF-8 text only. Newlines and tabs are valid; embedded NUL is not. +inline bool validBody(const char* body, size_t length) { + if (!body || !length || length > ROOM_MAIL_MAX_BODY_LENGTH) return false; + for (size_t i = 0; i < length;) { + const uint8_t first = uint8_t(body[i++]); + if (!first) return false; + if (first < 0x80) continue; + uint8_t count; uint32_t point, minimum; + if (first >= 0xc2 && first <= 0xdf) { count = 1; point = first & 0x1f; minimum = 0x80; } + else if (first >= 0xe0 && first <= 0xef) { count = 2; point = first & 0x0f; minimum = 0x800; } + else if (first >= 0xf0 && first <= 0xf4) { count = 3; point = first & 7; minimum = 0x10000; } + else return false; + if (count > length - i) return false; + while (count--) { + const uint8_t next = uint8_t(body[i++]); + if ((next & 0xc0) != 0x80) return false; + point = (point << 6) | (next & 0x3f); + } + if (point < minimum || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return false; + } + return true; +} +inline void bodyDigest(const char* body, size_t length, uint8_t* output) { + SHA256 sha; sha.update(body, length); sha.finalize(output, 32); +} +inline void ownerPath(const Entry& entry, char (&path)[24]) { + snprintf(path, sizeof(path), "/room_mail_%u.%c", unsigned(entry.slot), entry.bank ? 'b' : 'a'); +} +inline void encodeEntry(const Entry& entry, uint8_t (&bytes)[INDEX_ENTRY_SIZE]) { + memcpy(bytes, entry.owner, 32); bytes[32] = entry.slot; bytes[33] = entry.bank; + bytes[34] = entry.count; bytes[35] = 0; +} +inline void encodeRecord(const Record& r, uint8_t (&bytes)[RECORD_SIZE]) { + memset(bytes, 0, sizeof(bytes)); + if (!r.state) return; + memcpy(bytes, r.message.sender, 32); put64(bytes + 32, r.message.request_id); + put32(bytes + 40, r.message.id); put32(bytes + 44, r.message.created); + put16(bytes + 48, r.message.length); bytes[50] = r.state; + memcpy(bytes + 52, r.digest, 32); +} +template +auto openRead(Filesystem* fs, const char* path) +#if defined(RP2040_PLATFORM) + -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, "r"); } +#else + -> decltype(fs->open(path)) { return fs->open(path); } +#endif + +template +auto openWrite(Filesystem* fs, const char* path) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + -> decltype(fs->open(path, FILE_O_WRITE)) { return fs->open(path, FILE_O_WRITE); } +#elif defined(ESP32_PLATFORM) + -> decltype(fs->open(path, static_cast(nullptr), true)) { return fs->open(path, "w", true); } +#else + -> decltype(fs->open(path, static_cast(nullptr))) { return fs->open(path, "w"); } +#endif + +template +bool removeArtifact(Filesystem* fs, const char* path) { + bool present; + if (!filePresence(fs, path, present)) return false; + if (!present) return true; + fs->remove(path); + return filePresence(fs, path, present) && !present; +} + +// Index validation is streamed: no 32-owner RAM registry. Keys are sorted and +// slots are stable independently of ACL eviction or index cursor changes. +template +bool readIndex(Filesystem* fs, const char* path, Index& index, + const uint8_t* owner = nullptr, Entry* found = nullptr, + bool* has_owner = nullptr, const uint32_t* expected_crc = nullptr) { + index = Index{}; if (has_owner) *has_owner = false; + auto file = openRead(fs, path); if (!file) return false; + uint8_t header[INDEX_HEADER_SIZE]; + bool valid = !file.isDirectory() && size_t(file.read(header, sizeof(header))) == sizeof(header); + if (valid) valid = !memcmp(header, INDEX_MAGIC, 4) && get32(header + 4) != 0 + && header[8] <= ROOM_MAIL_MAX_OWNERS && header[9] <= ROOM_MAIL_MAX_MESSAGES + && zero(header + 10, 6) && file.size() == INDEX_HEADER_SIZE + header[8] * INDEX_ENTRY_SIZE + 4; + uint32_t sum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0; + uint8_t previous[32] = {}; uint8_t total = 0; + if (valid) { index.revision = get32(header + 4); index.count = header[8]; index.messages = header[9]; } + for (uint8_t i = 0; valid && i < index.count; ++i) { + uint8_t bytes[INDEX_ENTRY_SIZE]; + valid = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes); + if (!valid) break; + sum = crc(sum, bytes, sizeof(bytes)); + valid = nonzero(bytes) && bytes[32] < ROOM_MAIL_MAX_OWNERS && bytes[33] <= 1 + && bytes[34] <= ROOM_MAIL_MAX_PER_OWNER && bytes[35] == 0 + && !(index.slots & (UINT32_C(1) << bytes[32])) + && (i == 0 || memcmp(previous, bytes, 32) < 0); + if (!valid) break; + index.slots |= UINT32_C(1) << bytes[32]; total += bytes[34]; memcpy(previous, bytes, 32); + if (owner && !memcmp(owner, bytes, 32)) { + if (found) { memcpy(found->owner, bytes, 32); found->slot = bytes[32]; found->bank = bytes[33]; found->count = bytes[34]; } + if (has_owner) *has_owner = true; + } + } + uint8_t trailer[4]; sum ^= UINT32_C(0xffffffff); + if (valid) valid = total == index.messages && size_t(file.read(trailer, 4)) == 4 + && get32(trailer) == sum && (!expected_crc || sum == *expected_crc); + file.close(); + if (!valid) { index = Index{}; if (has_owner) *has_owner = false; } + return valid; +} + +// Present but invalid primary metadata is authoritative. Preserve all evidence. +// Only absence permits restoration of a verified backup. Temp is uncommitted. +template +bool recover(Filesystem* fs, Index& index, const uint8_t* owner, + Entry& entry, bool& found, bool cleanup) { + index = Index{}; found = false; + bool present; + if (!fs || !filePresence(fs, ROOM_MAIL_PRIMARY_PATH, present)) return false; + if (present) { + if (!readIndex(fs, ROOM_MAIL_PRIMARY_PATH, index, owner, &entry, &found)) return false; + } else { + if (!filePresence(fs, ROOM_MAIL_BACKUP_PATH, present)) return false; + if (present) { + if (!readIndex(fs, ROOM_MAIL_BACKUP_PATH, index, owner, &entry, &found) + || !fs->rename(ROOM_MAIL_BACKUP_PATH, ROOM_MAIL_PRIMARY_PATH)) return false; + } + } + if (cleanup) return removeArtifact(fs, ROOM_MAIL_TEMP_PATH) && removeArtifact(fs, ROOM_MAIL_BACKUP_PATH); + return true; +} + +// Entire selected bank is verified before releasing any requested slice. Owner +// metadata is optional: validation needs only fixed 88-byte/64-byte buffers. +template +bool readOwner(Filesystem* fs, const Entry& entry, Owner* out, + uint32_t selected_id = 0, size_t offset = 0, + uint8_t* output = nullptr, size_t capacity = 0, + const uint32_t* expected_crc = nullptr, + RoomMailStatus* status = nullptr, bool* mailbox_only = nullptr, + uint32_t max_revision = UINT32_MAX) { + if (out) clearOwner(*out); + if (status) clearStatus(*status); + if (mailbox_only) *mailbox_only = false; + char path[24]; ownerPath(entry, path); + auto file = openRead(fs, path); if (!file) return false; + uint8_t header[OWNER_HEADER_SIZE]; + bool valid = !file.isDirectory() && size_t(file.read(header, sizeof(header))) == sizeof(header); + if (valid) valid = !memcmp(header, OWNER_MAGIC, 4) && !memcmp(header + 4, entry.owner, 32) + && get32(header + 36) != 0 && get32(header + 36) <= max_revision + && header[40] == entry.slot && header[41] == entry.bank + && header[42] == entry.count && header[43] <= ROOM_MAIL_RETRY_RECEIPTS + && zero(header + 44, 4); + uint32_t sum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0; + uint8_t policy[4]; + if (valid) valid = size_t(file.read(policy, 4)) == 4 && policy[0] <= 2 && policy[1] <= 1 + && policy[2] <= ROOM_MAIL_MAX_ALLOWED && policy[3] == 0; + if (valid) { + sum = crc(sum, policy, 4); + if (out) { out->revision = get32(header + 36); out->count = header[42]; out->receipts = header[43]; + out->settings.mode = RoomMailMode(policy[0]); out->settings.mailbox_only = policy[1] != 0; + out->settings.allowed_count = policy[2]; } + if (status) { + status->revision = get32(header + 36); status->count = header[42]; + status->settings.mode = RoomMailMode(policy[0]); + status->settings.mailbox_only = policy[1] != 0; + status->settings.allowed_count = policy[2]; + } + } + uint8_t previous[32] = {}; + for (uint8_t i = 0; valid && i < ROOM_MAIL_MAX_ALLOWED; ++i) { + uint8_t key[32]; valid = size_t(file.read(key, 32)) == 32; + if (!valid) break; + sum = crc(sum, key, 32); + valid = i < policy[2] ? nonzero(key) && (i == 0 || memcmp(previous, key, 32) < 0) : zero(key, 32); + if (i < policy[2]) memcpy(previous, key, 32); + if (out) memcpy(out->settings.allowed[i], key, 32); + if (status) memcpy(status->settings.allowed[i], key, 32); + } + uint32_t previous_id = 0; uint8_t queued = 0; + size_t body_length = 0, selected_position = 0, selected_length = 0; + for (uint8_t i = 0; valid && i < ROOM_MAIL_RETRY_RECEIPTS; ++i) { + uint8_t bytes[RECORD_SIZE]; valid = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes); + if (!valid) break; + sum = crc(sum, bytes, sizeof(bytes)); + if (i >= header[43]) { valid = zero(bytes, sizeof(bytes)); continue; } + const uint32_t id = get32(bytes + 40); const uint16_t length = get16(bytes + 48); + valid = nonzero(bytes) && get64(bytes + 32) != 0 && id > previous_id + && id <= get32(header + 36) && length != 0 && length <= ROOM_MAIL_MAX_BODY_LENGTH + && bytes[50] >= Queued && bytes[50] <= Deleted && bytes[51] == 0 && zero(bytes + 84, 4); + previous_id = id; + if (out) { + Record& record = out->records[i]; memcpy(record.message.sender, bytes, 32); + record.message.request_id = get64(bytes + 32); record.message.id = id; + record.message.created = get32(bytes + 44); record.message.length = length; + record.state = bytes[50]; memcpy(record.digest, bytes + 52, 32); + record.offset = uint16_t(BODY_START + body_length); + } + if (bytes[50] == Queued) { + if (id == selected_id) { selected_position = body_length; selected_length = length; } + body_length += length; ++queued; + } + } + if (valid) valid = queued == entry.count && file.size() == BODY_START + body_length + 4; + uint8_t chunk[STREAM_CHUNK]; + for (size_t position = 0; valid && position < body_length;) { + const size_t remaining = body_length - position; + const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk); + valid = size_t(file.read(chunk, length)) == length; + if (!valid) break; + sum = crc(sum, chunk, length); + const size_t begin = selected_position + offset; + size_t wanted = selected_length > offset ? selected_length - offset : 0; + if (wanted > capacity) wanted = capacity; + if (output && selected_length && position + length > begin && position < begin + wanted) { + const size_t start = position < begin ? begin - position : 0; + size_t end = length; if (position + end > begin + wanted) end = begin + wanted - position; + memcpy(output + position + start - begin, chunk + start, end - start); + } + position += length; + } + uint8_t trailer[4]; sum ^= UINT32_C(0xffffffff); + if (valid) valid = size_t(file.read(trailer, 4)) == 4 && get32(trailer) == sum + && (!expected_crc || sum == *expected_crc); + file.close(); + if (!valid) { + if (out) clearOwner(*out); + if (status) clearStatus(*status); + if (output) memset(output, 0, capacity); + } else if (mailbox_only) *mailbox_only = policy[1] != 0; + return valid; +} + +template +bool writeBytes(File& file, uint32_t& sum, const uint8_t* bytes, size_t length) { + if (file.write(bytes, length) != length) return false; + sum = crc(sum, bytes, length); return true; +} + +template +bool writeOwner(Filesystem* fs, const Entry& entry, const Owner& owner, + bool had_owner, const Entry& previous, + uint32_t& written_checksum, + uint32_t new_id = 0, const char* new_body = nullptr) { + char path[24]; ownerPath(entry, path); + if (!removeArtifact(fs, path)) return false; + auto file = openWrite(fs, path); if (!file) return false; + uint8_t header[OWNER_HEADER_SIZE] = {}; + memcpy(header, OWNER_MAGIC, 4); memcpy(header + 4, entry.owner, 32); + put32(header + 36, owner.revision); header[40] = entry.slot; header[41] = entry.bank; + header[42] = owner.count; header[43] = owner.receipts; + uint32_t sum = UINT32_C(0xffffffff); + bool okay = !file.isDirectory() && writeBytes(file, sum, header, sizeof(header)); + uint8_t policy[4] = {uint8_t(owner.settings.mode), uint8_t(owner.settings.mailbox_only), owner.settings.allowed_count, 0}; + okay = okay && writeBytes(file, sum, policy, 4); + for (uint8_t i = 0; okay && i < ROOM_MAIL_MAX_ALLOWED; ++i) + okay = writeBytes(file, sum, owner.settings.allowed[i], 32); + for (uint8_t i = 0; okay && i < ROOM_MAIL_RETRY_RECEIPTS; ++i) { + uint8_t bytes[RECORD_SIZE]; encodeRecord(owner.records[i], bytes); + okay = writeBytes(file, sum, bytes, sizeof(bytes)); + } + // Adafruit LittleFS File requires its filesystem at construction. Obtain + // the source through open(), including its valid filesystem-bound failure + // value, rather than default-constructing an optional File handle. + Entry source_entry = previous; + if (!had_owner) source_entry.bank = 1 - entry.bank; + char source_path[24]; ownerPath(source_entry, source_path); + auto source = openRead(fs, source_path); + if (!had_owner && source) source.close(); + size_t source_position = 0; + uint8_t chunk[STREAM_CHUNK]; + for (uint8_t i = 0; okay && i < owner.receipts; ++i) { + const Record& record = owner.records[i]; if (record.state != Queued) continue; + if (record.message.id != new_id && (!had_owner || !source || source.isDirectory())) { okay = false; break; } + while (okay && record.message.id != new_id && source_position < record.offset) { + size_t length = record.offset - source_position; if (length > sizeof(chunk)) length = sizeof(chunk); + okay = size_t(source.read(chunk, length)) == length; source_position += length; + } + for (size_t position = 0; okay && position < record.message.length;) { + size_t length = record.message.length - position; if (length > sizeof(chunk)) length = sizeof(chunk); + if (record.message.id == new_id) { + if (!new_body) { okay = false; break; } + memcpy(chunk, new_body + position, length); + } else { okay = size_t(source.read(chunk, length)) == length; source_position += length; } + okay = okay && writeBytes(file, sum, chunk, length); position += length; + } + } + if (source) source.close(); + sum ^= UINT32_C(0xffffffff); uint8_t trailer[4]; put32(trailer, sum); + okay = okay && file.write(trailer, 4) == 4; file.flush(); file.close(); + written_checksum = sum; + return okay; +} + +// Publish a streamed sorted registry pointing at an already verified bank. +// A failed rename can leave a verified backup; recover() restores it on retry. +template +RoomMailResult publish(Filesystem* fs, const Index& prior, const Entry& entry, bool existed, + uint32_t revision) { + if (!removeArtifact(fs, ROOM_MAIL_TEMP_PATH) || !removeArtifact(fs, ROOM_MAIL_BACKUP_PATH)) + return RoomMailResult::WriteFailure; + auto file = openWrite(fs, ROOM_MAIL_TEMP_PATH); if (!file) return RoomMailResult::WriteFailure; + uint8_t header[INDEX_HEADER_SIZE] = {}; + memcpy(header, INDEX_MAGIC, 4); put32(header + 4, revision); + header[8] = prior.count + (existed ? 0 : 1); + uint8_t old_count = 0; + Entry ignored; Index checked; bool found = false; + if (existed && !readIndex(fs, ROOM_MAIL_PRIMARY_PATH, checked, entry.owner, &ignored, &found)) { + file.close(); return RoomMailResult::WriteFailure; + } + if (found) old_count = ignored.count; + header[9] = prior.messages - old_count + entry.count; + uint32_t sum = UINT32_C(0xffffffff); + bool okay = !file.isDirectory() && writeBytes(file, sum, header, sizeof(header)); + auto source = openRead(fs, ROOM_MAIL_PRIMARY_PATH); + if (prior.revision) { + uint8_t skipped[INDEX_HEADER_SIZE]; + okay = okay && source && !source.isDirectory() && size_t(source.read(skipped, sizeof(skipped))) == sizeof(skipped); + } + bool emitted = false; + for (uint8_t i = 0; okay && i < prior.count; ++i) { + uint8_t bytes[INDEX_ENTRY_SIZE]; okay = size_t(source.read(bytes, sizeof(bytes))) == sizeof(bytes); + if (!okay) break; + const int comparison = memcmp(entry.owner, bytes, 32); + if (!emitted && comparison <= 0) { + uint8_t added[INDEX_ENTRY_SIZE]; encodeEntry(entry, added); + okay = writeBytes(file, sum, added, sizeof(added)); emitted = true; + } + if (comparison != 0) okay = okay && writeBytes(file, sum, bytes, sizeof(bytes)); + } + if (okay && !emitted) { uint8_t bytes[INDEX_ENTRY_SIZE]; encodeEntry(entry, bytes); okay = writeBytes(file, sum, bytes, sizeof(bytes)); } + if (source) source.close(); + sum ^= UINT32_C(0xffffffff); uint8_t trailer[4]; put32(trailer, sum); + okay = okay && file.write(trailer, 4) == 4; file.flush(); file.close(); + Index verify; + if (!okay || !readIndex(fs, ROOM_MAIL_TEMP_PATH, verify, nullptr, nullptr, nullptr, &sum)) + return RoomMailResult::WriteFailure; + bool primary; + if (!filePresence(fs, ROOM_MAIL_PRIMARY_PATH, primary) + || (primary && !fs->rename(ROOM_MAIL_PRIMARY_PATH, ROOM_MAIL_BACKUP_PATH))) + return RoomMailResult::WriteFailure; + if (!fs->rename(ROOM_MAIL_TEMP_PATH, ROOM_MAIL_PRIMARY_PATH)) { + if (primary) fs->rename(ROOM_MAIL_BACKUP_PATH, ROOM_MAIL_PRIMARY_PATH); + return RoomMailResult::WriteFailure; + } + // Registry rename is the commit. Housekeeping failure never undoes success. + removeArtifact(fs, ROOM_MAIL_BACKUP_PATH); + return RoomMailResult::Success; +} + +template +RoomMailResult commit(Filesystem* fs, Index& index, Entry& entry, Owner& owner, + bool existed, uint32_t new_id = 0, const char* new_body = nullptr) { + if (index.revision == UINT32_MAX) return RoomMailResult::Unavailable; + Entry previous = entry; + owner.revision = index.revision + 1; entry.bank = existed ? 1 - entry.bank : 0; entry.count = owner.count; + uint32_t checksum; + if (!writeOwner(fs, entry, owner, existed, previous, checksum, new_id, new_body) + || !readOwner(fs, entry, nullptr, 0, 0, nullptr, 0, &checksum)) + return RoomMailResult::WriteFailure; + return publish(fs, index, entry, existed, owner.revision); +} +template +RoomMailResult load(Filesystem* fs, const uint8_t* key, Index& index, Entry& entry, Owner& owner, + bool& found, bool cleanup = false) { + if (!nonzero(key)) return RoomMailResult::Invalid; + if (!recover(fs, index, key, entry, found, false)) return RoomMailResult::Unavailable; + if (found && (!readOwner(fs, entry, &owner) || owner.revision > index.revision)) + return RoomMailResult::Unavailable; + // Validate the selected committed bank before removing recovery evidence. + if (cleanup && (!removeArtifact(fs, ROOM_MAIL_TEMP_PATH) + || !removeArtifact(fs, ROOM_MAIL_BACKUP_PATH))) return RoomMailResult::Unavailable; + if (!found) { clearOwner(owner); return RoomMailResult::NotFound; } + return RoomMailResult::Success; +} +} // namespace room_mail_detail + +template +RoomMailResult getRoomMailStatus(Filesystem* fs, const uint8_t* owner, RoomMailStatus& status) { + room_mail_detail::clearStatus(status); room_mail_detail::Index index; room_mail_detail::Entry entry; + bool found; + if (!room_mail_detail::nonzero(owner)) return RoomMailResult::Invalid; + if (!room_mail_detail::recover(fs, index, owner, entry, found, false)) return RoomMailResult::Unavailable; + if (!found) return RoomMailResult::NotFound; + return room_mail_detail::readOwner(fs, entry, nullptr, 0, 0, nullptr, 0, + nullptr, &status, nullptr, index.revision) ? RoomMailResult::Success : RoomMailResult::Unavailable; +} + +template +RoomMailResult getRoomMailboxOnly(Filesystem* fs, const uint8_t* owner, bool& value) { + value = false; room_mail_detail::Index index; room_mail_detail::Entry entry; bool found; + if (!room_mail_detail::nonzero(owner)) return RoomMailResult::Invalid; + if (!room_mail_detail::recover(fs, index, owner, entry, found, false)) return RoomMailResult::Unavailable; + if (!found) return RoomMailResult::Success; + return room_mail_detail::readOwner(fs, entry, nullptr, 0, 0, nullptr, 0, + nullptr, nullptr, &value, index.revision) ? RoomMailResult::Success : RoomMailResult::Unavailable; +} + +template +RoomMailResult saveRoomMailSettings(Filesystem* fs, const uint8_t* key, + const RoomMailSettings& settings, uint32_t expected_revision) { + using namespace room_mail_detail; + if (!nonzero(key) || !validSettings(settings)) return RoomMailResult::Invalid; + Index index; Entry entry; Owner state; bool found; + const auto result = load(fs, key, index, entry, state, found, true); + if (result != RoomMailResult::Success && result != RoomMailResult::NotFound) return result; + if (expected_revision != state.revision) return RoomMailResult::StaleVersion; + if (!found) { + if (index.count >= ROOM_MAIL_MAX_OWNERS) return RoomMailResult::Full; + memcpy(entry.owner, key, 32); entry.slot = 0; + while (index.slots & (UINT32_C(1) << entry.slot)) ++entry.slot; + } + state.settings = settings; + for (uint8_t i = settings.allowed_count; i < ROOM_MAIL_MAX_ALLOWED; ++i) memset(state.settings.allowed[i], 0, 32); + for (uint8_t i = 1; i < settings.allowed_count; ++i) + for (uint8_t j = i; j > 0 && memcmp(state.settings.allowed[j - 1], state.settings.allowed[j], 32) > 0; --j) { + uint8_t swap[32]; memcpy(swap, state.settings.allowed[j - 1], 32); + memcpy(state.settings.allowed[j - 1], state.settings.allowed[j], 32); memcpy(state.settings.allowed[j], swap, 32); + } + return commit(fs, index, entry, state, found); +} + +// Convenience for local single-loop callers. Explicit revision arguments are +// always strict: revision zero can provision only a still-absent mailbox. +template +RoomMailResult saveRoomMailSettings(Filesystem* fs, const uint8_t* key, + const RoomMailSettings& settings) { + RoomMailStatus status; + const auto result = getRoomMailStatus(fs, key, status); + if (result != RoomMailResult::Success && result != RoomMailResult::NotFound) return result; + return saveRoomMailSettings(fs, key, settings, status.revision); +} + +template +RoomMailResult listRoomMail(Filesystem* fs, const uint8_t* key, uint32_t expected_revision, + size_t start, RoomMailMessage* output, size_t capacity, size_t& copied) { + copied = 0; + if (!output || !capacity || capacity > ROOM_MAIL_MAX_PER_OWNER) return RoomMailResult::Invalid; + room_mail_detail::Index index; room_mail_detail::Entry entry; room_mail_detail::Owner state; bool found; + const auto result = room_mail_detail::load(fs, key, index, entry, state, found); + if (result != RoomMailResult::Success) return result; + if (expected_revision && expected_revision != state.revision) return RoomMailResult::StaleVersion; + if (start > state.count) return RoomMailResult::Invalid; + size_t cursor = 0; + for (uint8_t i = 0; i < state.receipts && copied < capacity; ++i) + if (state.records[i].state == room_mail_detail::Queued && cursor++ >= start) output[copied++] = state.records[i].message; + return RoomMailResult::Success; +} + +template +RoomMailResult readRoomMail(Filesystem* fs, const uint8_t* key, uint32_t id, size_t offset, + uint8_t* output, size_t capacity, size_t& copied, RoomMailMessage& message) { + copied = 0; message = RoomMailMessage{}; + if (!id || !output || !capacity || capacity > ROOM_MAIL_READ_MAX_CHUNK) return RoomMailResult::Invalid; + room_mail_detail::Index index; room_mail_detail::Entry entry; room_mail_detail::Owner state; bool found; + const auto result = room_mail_detail::load(fs, key, index, entry, state, found); + if (result != RoomMailResult::Success) return result; + for (uint8_t i = 0; i < state.receipts; ++i) { + const auto& record = state.records[i]; + if (record.message.id != id || record.state != room_mail_detail::Queued) continue; + if (offset > record.message.length) return RoomMailResult::Invalid; + if (!room_mail_detail::readOwner(fs, entry, nullptr, id, offset, output, capacity)) return RoomMailResult::Unavailable; + message = record.message; copied = message.length - offset; if (copied > capacity) copied = capacity; + return RoomMailResult::Success; + } + return RoomMailResult::NotFound; +} + +// Up to eight accepted submission receipts remain: queued receipts never evict, +// and the oldest retired receipt is replaced when needed. Exact nonce/body +// retries return Duplicate across reboot and after +// ACK/delete. Reusing a retained nonce with different content returns Mismatch. +// An ancient retry after its retired receipt is evicted can enqueue again. +template +RoomMailResult sendRoomMail(Filesystem* fs, const uint8_t* sender, const uint8_t* key, + uint64_t request_id, const char* body, size_t length, + uint32_t& id, uint32_t created = 0) { + using namespace room_mail_detail; + id = 0; + if (!nonzero(sender) || !nonzero(key) || !request_id || !validBody(body, length)) return RoomMailResult::Invalid; + Index index; Entry entry; Owner state; bool found; + const auto result = load(fs, key, index, entry, state, found, true); + if (result != RoomMailResult::Success) return result; + if (!roomMailSenderAllowed(state.settings, key, sender)) return RoomMailResult::Forbidden; + uint8_t digest[32]; bodyDigest(body, length, digest); + for (uint8_t i = 0; i < state.receipts; ++i) { + const auto& record = state.records[i]; + if (record.message.request_id != request_id || memcmp(record.message.sender, sender, 32)) continue; + if (record.message.length != length || memcmp(record.digest, digest, 32)) return RoomMailResult::Mismatch; + id = record.message.id; return RoomMailResult::Duplicate; + } + if (state.count >= ROOM_MAIL_MAX_PER_OWNER || index.messages >= ROOM_MAIL_MAX_MESSAGES) return RoomMailResult::Full; + if (index.revision == UINT32_MAX) return RoomMailResult::Unavailable; + if (state.receipts == ROOM_MAIL_RETRY_RECEIPTS) { + uint8_t retired = 0; while (retired < state.receipts && state.records[retired].state == Queued) ++retired; + if (retired == state.receipts) return RoomMailResult::Full; + for (uint8_t i = retired; i + 1 < state.receipts; ++i) state.records[i] = state.records[i + 1]; + --state.receipts; + } + Record& added = state.records[state.receipts++]; added = Record{}; + added.message.id = index.revision + 1; added.message.request_id = request_id; + added.message.created = created; added.message.length = uint16_t(length); + memcpy(added.message.sender, sender, 32); memcpy(added.digest, digest, 32); + added.state = Queued; ++state.count; + const auto saved = commit(fs, index, entry, state, true, added.message.id, body); + if (saved == RoomMailResult::Success) id = added.message.id; + return saved; +} + +namespace room_mail_detail { +template +RoomMailResult retire(Filesystem* fs, const uint8_t* key, uint32_t id, uint8_t kind, bool purge) { + Index index; Entry entry; Owner state; bool found; + const auto result = load(fs, key, index, entry, state, found, true); + if (result != RoomMailResult::Success) return result; + bool known = false, changed = false; + for (uint8_t i = 0; i < state.receipts; ++i) { + auto& record = state.records[i]; + if (!purge && record.message.id != id) continue; + known = true; + if (record.state == Queued) { record.state = kind; --state.count; changed = true; } + } + if (!known && !purge) return RoomMailResult::NotFound; + if (!changed) return RoomMailResult::Success; + return commit(fs, index, entry, state, true); +} +} // namespace room_mail_detail + +template +RoomMailResult acknowledgeRoomMail(Filesystem* fs, const uint8_t* owner, uint32_t id) { + if (!id) return RoomMailResult::Invalid; + return room_mail_detail::retire(fs, owner, id, room_mail_detail::Acknowledged, false); +} +template +RoomMailResult deleteRoomMail(Filesystem* fs, const uint8_t* owner, uint32_t id) { + return room_mail_detail::retire(fs, owner, id, room_mail_detail::Deleted, id == 0); +} + +template +RoomMailResult getRoomMailStats(Filesystem* fs, RoomMailStats& stats) { + stats = RoomMailStats{}; room_mail_detail::Index index; room_mail_detail::Entry entry; bool found; + if (!room_mail_detail::recover(fs, index, nullptr, entry, found, false)) return RoomMailResult::Unavailable; + stats.revision = index.revision; stats.owners = index.count; stats.messages = index.messages; + return RoomMailResult::Success; +} + +// Administrator-only owner enumeration. Caller must authorize this explicitly. +// Public mailbox reads never disclose the registry or another owner's policy. +template +RoomMailResult listRoomMailOwners(Filesystem* fs, uint32_t expected_revision, size_t start, + uint8_t (*keys)[32], size_t capacity, size_t& copied, + RoomMailStats& stats) { + copied = 0; + if (!keys || !capacity || capacity > ROOM_MAIL_MAX_OWNERS) return RoomMailResult::Invalid; + const auto result = getRoomMailStats(fs, stats); + if (result != RoomMailResult::Success) return result; + if (expected_revision && expected_revision != stats.revision) return RoomMailResult::StaleVersion; + if (start > stats.owners) return RoomMailResult::Invalid; + if (!stats.owners) return RoomMailResult::Success; + auto file = room_mail_detail::openRead(fs, ROOM_MAIL_PRIMARY_PATH); if (!file) return RoomMailResult::Unavailable; + uint8_t header[room_mail_detail::INDEX_HEADER_SIZE]; + bool okay = size_t(file.read(header, sizeof(header))) == sizeof(header); + for (size_t i = 0; okay && i < stats.owners && copied < capacity; ++i) { + uint8_t bytes[room_mail_detail::INDEX_ENTRY_SIZE]; okay = size_t(file.read(bytes, sizeof(bytes))) == sizeof(bytes); + if (okay && i >= start) memcpy(keys[copied++], bytes, 32); + } + file.close(); + if (!okay) { copied = 0; return RoomMailResult::Unavailable; } + return RoomMailResult::Success; +} +} // namespace mesh diff --git a/src/helpers/RoomMailWeb.h b/src/helpers/RoomMailWeb.h new file mode 100644 index 000000000..b7e1d32ae --- /dev/null +++ b/src/helpers/RoomMailWeb.h @@ -0,0 +1,247 @@ +#pragma once + +#include "RoomMailStore.h" +#include "RoomWebJson.h" + +namespace mesh { + +inline void roomMailWebHex(char* output, const uint8_t* key, size_t length) { + static const char digits[] = "0123456789abcdef"; + for (size_t i = 0; i < length; ++i) { + output[2 * i] = digits[key[i] >> 4]; output[2 * i + 1] = digits[key[i] & 15]; + } + output[length * 2] = 0; +} + +inline bool roomMailWebKey(uint8_t* output, size_t length, const char* text) { + if (!text || strlen(text) != 2 * length) return false; + for (size_t i = 0; i < length; ++i) { + unsigned byte = 0; + for (size_t n = 0; n < 2; ++n) { + const char c = text[2 * i + n]; + unsigned digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else return false; + byte = 16 * byte + digit; + } + output[i] = uint8_t(byte); + } + return true; +} + +inline const char* roomMailModeName(RoomMailMode mode) { + return mode == RoomMailMode::Public ? "public" : mode == RoomMailMode::Private ? "private" : "closed"; +} + +inline const char* roomMailError(RoomMailResult result) { + switch (result) { + case RoomMailResult::Invalid: return "invalid mailbox request"; + case RoomMailResult::Unavailable: return "mailbox storage unavailable; previous data retained"; + case RoomMailResult::WriteFailure: return "mailbox save failed; previous data retained"; + case RoomMailResult::NotFound: return "mailbox or message not found"; + case RoomMailResult::StaleVersion: return "mailbox changed; refresh before editing"; + case RoomMailResult::Forbidden: return "recipient is not accepting messages from this identity"; + case RoomMailResult::Full: return "mailbox or server queue full; message not accepted"; + case RoomMailResult::Mismatch: return "request identifier already used for different mail"; + default: return "mailbox request failed"; + } +} + +inline void writeRoomMailSettingsJson(RoomJsonWriter& json, const uint8_t* owner, + const RoomMailStatus& status, bool blocked = false) { + char key[65]; roomMailWebHex(key, owner, 32); + json.raw("{\"owner\":"); json.string(key); + json.raw(",\"revision\":"); json.number(status.revision); + json.raw(",\"mode\":"); json.string(roomMailModeName(status.settings.mode)); + json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false"); + json.raw(",\"allowlist\":["); + for (uint8_t i = 0; i < status.settings.allowed_count; ++i) { + if (i) json.raw(","); + roomMailWebHex(key, status.settings.allowed[i], 32); json.string(key); + } + json.raw("],\"total\":"); json.number(status.count); + json.raw(",\"unread\":"); json.number(status.count); + json.raw(",\"blocked\":"); json.raw(blocked ? "true" : "false"); json.raw("}"); +} + +// The caller has already authenticated the browser's own token identity and +// charged room quotas. Only explicit administrator operations may select a +// different owner; a shared room password never proves a radio's private key. +template +bool handleRoomMailWeb(Filesystem* fs, const uint8_t* caller, bool can_send, + bool administrator, uint64_t request_id, uint32_t created, + JsonObjectConst input, RoomJsonWriter& json, + AllowsIdentity allows) { + const char* operation = input["op"].as(); + if (!operation) return false; + const bool admin = strncmp(operation, "admin.mail.", 11) == 0; + if (!admin && strncmp(operation, "mail.", 5) != 0) return false; + auto error = [&json](const char* message) { + json.reset(); + json.raw("{\"error\":"); json.string(message); json.raw("}"); json.finish(); + }; + if (json.capacity() < 1024) { error("mailbox reply capacity too small"); return true; } + auto checked = [&error](RoomMailResult result) { + if (result == RoomMailResult::Success || result == RoomMailResult::Duplicate) return true; + error(roomMailError(result)); return false; + }; + auto keyValue = [](JsonVariantConst value, uint8_t* out) { + if (!value.is()) return false; + const JsonString text = value.as(); + if (text.size() != 64 || strlen(text.c_str()) != 64 + || !roomMailWebKey(out, 32, text.c_str())) return false; + uint8_t any = 0; for (size_t i = 0; i < 32; ++i) any |= out[i]; + return any != 0; + }; + if (admin && !administrator) { error("admin permission required"); return true; } + uint8_t owner[32]; memcpy(owner, caller, sizeof(owner)); + if (admin && strcmp(operation, "admin.mail.list") != 0 + && !keyValue(input["owner"], owner)) { + error("complete mailbox owner public key required"); return true; + } + if (strcmp(operation, "admin.mail.list") == 0) { + if (!input["cursor"].is() || !input["revision"].is() + || (input["cursor"].as() && !input["revision"].as())) { + error("mailbox cursor and snapshot revision required"); return true; + } + uint8_t keys[2][32]; size_t copied = 0; RoomMailStats stats; + const uint8_t cursor = input["cursor"].as(); + if (!checked(listRoomMailOwners(fs, input["revision"].as(), cursor, + keys, 2, copied, stats))) return true; + json.raw("{\"mailboxes\":["); + for (size_t i = 0; i < copied; ++i) { + RoomMailStatus status; + const auto result = getRoomMailStatus(fs, keys[i], status); + if (result != RoomMailResult::Success) { error(roomMailError(result)); return true; } + if (i) json.raw(","); + char key[65]; roomMailWebHex(key, keys[i], 32); + json.raw("{\"owner\":"); json.string(key); + json.raw(",\"mode\":"); json.string(roomMailModeName(status.settings.mode)); + json.raw(",\"revision\":"); json.number(status.revision); + json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false"); + json.raw(",\"unread\":"); json.number(status.count); + json.raw(",\"total\":"); json.number(status.count); + json.raw(",\"blocked\":"); json.raw(allows(keys[i]) ? "false" : "true"); json.raw("}"); + } + json.raw("],\"next\":"); + if (cursor + copied < stats.owners) json.number(cursor + copied); else json.raw("null"); + json.raw(",\"total\":"); json.number(stats.owners); + json.raw(",\"queued\":"); json.number(stats.messages); + json.raw(",\"revision\":"); json.number(stats.revision); json.raw("}"); json.finish(); return true; + } + if (strcmp(operation, "mail.send") == 0) { + if (!can_send) { error("read-only room access cannot send mail"); return true; } + uint8_t recipient[32]; + if (!keyValue(input["recipient"], recipient)) { error("complete recipient public key required"); return true; } + if (!allows(recipient)) { error("recipient is blocked"); return true; } + if (!input["text"].is()) { error("message required"); return true; } + const JsonString text = input["text"].as(); + if (!text.size() || text.size() > 512 || strlen(text.c_str()) != text.size()) { + error("mail must contain 1-512 UTF-8 bytes"); return true; + } + uint32_t id = 0; + if (!checked(sendRoomMail(fs, caller, recipient, request_id, text.c_str(), text.size(), id, created))) return true; + json.raw("{\"ok\":true,\"id\":"); json.number(id); json.raw("}"); json.finish(); return true; + } + if (strcmp(operation, "mail.policy") == 0 || strcmp(operation, "admin.mail.policy") == 0) { + if (!input["expected_revision"].is() || !input["mailbox_only"].is() + || !input["mode"].is() || !input["allowlist"].is()) { + error("mailbox mode, delivery, sender list and expected revision required"); return true; + } + RoomMailSettings settings; + const char* mode = input["mode"].as(); + if (strcmp(mode, "public") == 0) settings.mode = RoomMailMode::Public; + else if (strcmp(mode, "private") == 0) settings.mode = RoomMailMode::Private; + else if (strcmp(mode, "closed") != 0) { error("use public, private or closed mailbox access"); return true; } + settings.mailbox_only = input["mailbox_only"].as(); + JsonArrayConst allowed = input["allowlist"].as(); + if (allowed.size() > 8) { error("at most eight selected senders"); return true; } + for (JsonVariantConst value : allowed) { + if (!keyValue(value, settings.allowed[settings.allowed_count])) { + error("selected senders need complete public keys"); return true; + } + ++settings.allowed_count; + } + if (!checked(saveRoomMailSettings(fs, owner, settings, input["expected_revision"].as()))) return true; + } + if (strcmp(operation, "mail.ack") == 0 || strcmp(operation, "mail.delete") == 0 + || strcmp(operation, "admin.mail.delete") == 0) { + if (!input["id"].is() || (!admin && input["id"].as() == 0)) { + error("message identifier required"); return true; + } + const auto result = strcmp(operation, "mail.ack") == 0 + ? acknowledgeRoomMail(fs, owner, input["id"].as()) + : deleteRoomMail(fs, owner, input["id"].as()); + if (!checked(result)) return true; + json.raw("{\"ok\":true}"); json.finish(); return true; + } + RoomMailStatus status; + const auto loaded = getRoomMailStatus(fs, owner, status); + if (loaded != RoomMailResult::Success && loaded != RoomMailResult::NotFound) { + error(roomMailError(loaded)); return true; + } + if (strcmp(operation, "mail.settings") == 0 || strcmp(operation, "mail.policy") == 0 + || strcmp(operation, "admin.mail.settings") == 0 || strcmp(operation, "admin.mail.policy") == 0) { + writeRoomMailSettingsJson(json, owner, status, !allows(owner)); json.finish(); return true; + } + if (strcmp(operation, "mail.check") == 0) { + char key[65]; roomMailWebHex(key, owner, 32); + json.raw("{\"owner\":"); json.string(key); + json.raw(",\"unread\":"); json.number(status.count); + json.raw(",\"total\":"); json.number(status.count); + json.raw(",\"revision\":"); json.number(status.revision); + json.raw(",\"mailbox_only\":"); json.raw(status.settings.mailbox_only ? "true" : "false"); + json.raw("}"); json.finish(); return true; + } + if (strcmp(operation, "mail.list") == 0) { + if (!input["cursor"].is() || !input["revision"].is() + || (input["cursor"].as() && !input["revision"].as())) { + error("mail cursor and snapshot revision required"); return true; + } + const uint8_t cursor = input["cursor"].as(); + const uint32_t expected_revision = input["revision"].as(); + if (expected_revision && expected_revision != status.revision) { + error(roomMailError(RoomMailResult::StaleVersion)); return true; + } + if (loaded == RoomMailResult::NotFound) { + if (cursor) { error("invalid mail cursor"); return true; } + json.raw("{\"messages\":[],\"next\":null,\"revision\":0}"); json.finish(); return true; + } + RoomMailMessage messages[2]; size_t copied = 0; + if (!checked(listRoomMail(fs, owner, status.revision, cursor, messages, 2, copied))) return true; + json.raw("{\"messages\":["); + for (size_t i = 0; i < copied; ++i) { + if (i) json.raw(","); char key[65]; roomMailWebHex(key, messages[i].sender, 32); + json.raw("{\"id\":"); json.number(messages[i].id); + json.raw(",\"sender\":"); json.string(key); + json.raw(",\"created\":"); json.number(messages[i].created); + json.raw(",\"length\":"); json.number(messages[i].length); json.raw(",\"acked\":false}"); + } + json.raw("],\"next\":"); + if (cursor + copied < status.count) json.number(cursor + copied); else json.raw("null"); + json.raw(",\"revision\":"); json.number(status.revision); json.raw("}"); json.finish(); return true; + } + if (strcmp(operation, "mail.read") == 0) { + if (!input["id"].is() || !input["offset"].is()) { + error("message identifier and offset required"); return true; + } + uint8_t bytes[128]; char encoded[173]; size_t copied = 0; RoomMailMessage message; + const uint16_t offset = input["offset"].as(); + if (!checked(readRoomMail(fs, owner, input["id"].as(), offset, + bytes, sizeof(bytes), copied, message))) return true; + roomEncodeBase64(bytes, copied, encoded); char key[65]; roomMailWebHex(key, message.sender, 32); + json.raw("{\"id\":"); json.number(message.id); + json.raw(",\"sender\":"); json.string(key); + json.raw(",\"created\":"); json.number(message.created); + json.raw(",\"length\":"); json.number(message.length); + json.raw(",\"offset\":"); json.number(offset); + json.raw(",\"count\":"); json.number(copied); + json.raw(",\"next\":"); json.number(offset + copied); + json.raw(",\"data64\":"); json.string(encoded); json.raw(",\"acked\":false}"); json.finish(); return true; + } + error("unknown mailbox operation"); return true; +} + +} // namespace mesh diff --git a/src/helpers/RoomWebJson.h b/src/helpers/RoomWebJson.h index 6e86efa66..adeef86dc 100644 --- a/src/helpers/RoomWebJson.h +++ b/src/helpers/RoomWebJson.h @@ -48,6 +48,11 @@ public: if (!output || !capacity) valid_ = false; else output[0] = 0; } + void reset() { + used_ = 0; valid_ = output_ && capacity_; + if (valid_) output_[0] = 0; + } + size_t capacity() const { return capacity_; } void raw(const char* text) { const size_t length = strlen(text); if (!valid_ || length >= capacity_ - used_) { valid_ = false; return; } diff --git a/src/helpers/esp32/RoomWebHtml.h b/src/helpers/esp32/RoomWebHtml.h index 73471d06a..e40074573 100644 --- a/src/helpers/esp32/RoomWebHtml.h +++ b/src/helpers/esp32/RoomWebHtml.h @@ -16,9 +16,10 @@ button:disabled{opacity:.45;cursor:default}label{display:block}.muted{color:#b5b .post{padding:10px 0;border-bottom:1px solid #364351}.post small{display:block;color:#b5beca} details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color:#a8d5ff}[hidden]{display:none!important} .panel{border:1px solid #526273;border-radius:10px;padding:18px;margin:20px 0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}.card{background:#202b38;padding:12px;border-radius:6px}.card strong{display:block;font-size:1.3em}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:.92em}th,td{text-align:left;padding:9px 7px;border-bottom:1px solid #364351;vertical-align:top}th{color:#b5beca}.key{font-family:monospace;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}.notice{padding:10px;background:#273947;border-radius:6px}.warning{color:#ffd3a3}.success{color:#a5e1b1}dl{display:grid;grid-template-columns:minmax(110px,1fr) 2fr;gap:8px}dt{color:#b5beca}dd{margin:0;overflow-wrap:anywhere}select{max-width:100%}.inline{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.inline label{display:inline}.inline input{width:auto;margin:0}fieldset{border:1px solid #526273;border-radius:6px;margin:14px 0;padding:12px}.small{font-size:.9em} +#mail-panel fieldset,#mail-panel .grid>form{min-width:0}#mail-panel select{width:100%}

MeshCore Room

-

Chat and selected notices over local Wi-Fi. This page also works without internet access.

+

Chat, personal mail and selected notices over local Wi-Fi. This page also works without internet access.

@@ -36,6 +37,26 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color

+

Personal mailbox

+

Mail waits on this room until you collect it. Your browser mailbox uses its own identity, separate from your radio.

+ + +

Information board