QMail Ping — Group 6, Code 72
Long-poll inbox wait. The client opens a TCP request to the recipient’s beacon RAIDA. If tells are already waiting, the server responds immediately. If none are ready, the server parks the connection until a tell arrives or the long-poll watcher times out.
Retained notifications (server source since 2026-09-09)
Ping, Peek and resumed Ping do not delete Tells. A Tell stays in the inbox for the 30-day retention window measured from its latest publication, regardless of how many times it was fetched. Any client may see the same Tell more than once and must persist each notification locally before advancing its cursor. Deployed fleet support should be verified against the server build; this page describes the raidax source at commit bcc93e8. See the safety amendment and remaining ACK/cursor work, and the proposed Wait (group 22, command 2), Ack (group 22, command 3) and Beacon Status (group 22, command 1) commands.
Quick reference
| Command Group | 6 (QMail) |
| Command Code | 72 (0x48) |
| Server function | cmd_qmail_ping in cmd_qmail.c |
| Request packet | 32-byte plaintext RAIDA header + encrypted body fields + plaintext 3E 3E terminator |
| Request body | 54 bytes total: encrypted challenge(16) + qmail identity/auth(32) + since_timestamp(4), then plaintext terminator(2). Identical to peek. |
| Minimum accepted body | 50 bytes: a body without the 4-byte timestamp is accepted and treated as since_timestamp = 0 (every retained Tell matches) |
| Success response | 32-byte plaintext RAIDA response header + encrypted tell-list payload + plaintext 3E 3E terminator |
| No-mail response | No immediate response. The TCP connection is parked in STATE_PING_ACTIVE for up to 15 minutes; expiry is checked once a minute, so a status 245 (STATUS_TIMEOUT) reply with an empty body arrives 15–16 minutes after parking. A client whose socket timeout is shorter than that never sees it. |
| Side effect on success | None. Returned Tells stay in the inbox until retention expires. |
| Encryption | Required — AES-128-CTR, type 1, keyed by the caller’s selected coin AN for this RAIDA |
Purpose
ping is QMail’s long-poll receive primitive. It scans the recipient mailbox directory for .tell files whose sender timestamp is greater than the request’s since_timestamp, exactly as peek does. If one or more match, the server returns them at once using the same tell-list payload format as peek. If none match, it creates the inbox directory if needed, registers a one-shot inotify watcher, and leaves the TCP connection open until a new .tell lands or the watcher expires.
The server path is selected from the mailbox identity in the request body: QMAIL_MAILBOX_ROOT/{denom_hex}/{serial_number}/inbox/. The request must be sent to the recipient’s beacon RAIDA; other RAIDAs may not have that mailbox coin loaded.
Wire packet
The RAIDA packet header is the 32 plaintext bytes at the front of the TCP packet. The 16-byte challenge/CRC is encrypted with the rest of the request body. raidax groups that challenge with the QMail identity block and calls those first 48 decrypted body bytes qmail_preamble_t.
QMail Ping request packet, bytes on TCP
+-------------------------------+-----------------------------------------+----------------------+
| RAIDA request header | encrypted request body | plaintext terminator |
| 32 bytes | 52 bytes (48 minimum) | 2 bytes |
+-------------------------------+-----------------------------------------+----------------------+
| command group=6, command=72 | challenge/CRC(16) | 3E 3E |
| encryption type=1 | QMail identity/auth block(32) | |
| body_size=54 (50 minimum) | since_timestamp(4) | |
| nonce(8) | | |
+-------------------------------+-----------------------------------------+----------------------+
rest_core sends body_size = 54 (0x0036), the same builder as peek. raidax accepts a 50-byte body without the timestamp and then scans with since_timestamp = 0, which returns every retained Tell in the inbox. Always send the timestamp.
Request header (32 bytes, plaintext)
| Offset | Size | Field | Ping value / meaning |
|---|---|---|---|
| 0 | 1 | Router version | 0x01 |
| 1 | 1 | Split ID | 0x00 for the standard single-frame request |
| 2 | 1 | RAIDA ID | Target beacon RAIDA index, 0–24 |
| 3 | 1 | Shard ID | Current rest_core sends shard 0x03 |
| 4 | 1 | Command Group | 0x06 (QMail) |
| 5 | 1 | Command Code | 0x48 (72, ping) |
| 6–7 | 2 | Coin ID | 00 06 CloudCoin network ID |
| 8 | 1 | Bitfield | 0x01 |
| 9–15 | 7 | Reserved | Zero-filled by rest_core |
| 16 | 1 | Encryption type | 0x01 AES-128-CTR with coin AN |
| 17 | 1 | Encryption key denomination | Denomination of the coin used for wire encryption |
| 18–21 | 4 | Encryption key SN | Serial number of the coin used for wire encryption, big-endian |
| 22–23 | 2 | Body size | 00 36 (54) with the timestamp; 00 32 (50) is the minimum accepted and means since = 0 |
| 24–31 | 8 | Nonce | AES-CTR nonce used to encrypt/decrypt the request body |
Request body (54 bytes)
Decrypted request body layout
+-------------------+--------------------------+---------------------+----------------------+
| challenge/CRC | QMail identity/auth | since_timestamp | terminator |
| 16 bytes | 32 bytes | 4 bytes | 2 bytes |
| encrypted | encrypted | encrypted | plaintext |
+-------------------+--------------------------+---------------------+----------------------+
| body[0..15] | body[16..47] | body[48..51] | body[52..53] |
| required | required | 0 if omitted | required |
+-------------------+--------------------------+---------------------+----------------------+
| Body offset | Size | Encrypted? | Field | Description |
|---|---|---|---|---|
| 0–11 | 12 | Yes | Challenge random | Random bytes generated by the client. |
| 12–15 | 4 | Yes | Challenge CRC32 | Big-endian CRC32 of body bytes 0–11. The server verifies this after decrypting. |
| 16–23 | 8 | Yes | Session ID | All zeros for current QMail AES-128 requests. |
| 24–25 | 2 | Yes | Coin Type | 00 06. |
| 26 | 1 | Yes | Mailbox denomination | Recipient mailbox coin denomination. |
| 27–30 | 4 | Yes | Mailbox serial number | Recipient mailbox serial number, big-endian. |
| 31 | 1 | Yes | Reserved | Was Device ID. The server ignores it. The shipped rest_core client currently writes 1 here (its device_id); new clients should write zero. Because live clients send a non-zero value, this byte is not available for a compatible flags extension. |
| 32–47 | 16 | Yes | Authenticity Number | The mailbox coin AN for this RAIDA. After decryption, process_inbox_scan() compares it to the stored AN for the body’s denomination/SN. |
| 48–51 | 4 | Yes | since_timestamp | Big-endian Unix seconds. cmd_qmail_ping reads it with qmail_request_since_timestamp() and passes it to process_inbox_scan(); only Tells whose sender timestamp (file header bytes 24–27) is strictly greater are returned. The same cursor is reused when the parked poll is woken. A body without this field is treated as 0. |
| last 2 | 2 | No | Terminator | Fixed 3E 3E. Included in body_size but not encrypted. |
raidax names body offsets 0–47 qmail_preamble_t. In the terminology above, the challenge is still body data because it is encrypted with the rest of the body fields.
Response packet
There are three normal ping outcomes: a tell-list success response when matching mail is available now or arrives during the poll; a success response with tell_count = 0 when the watcher was woken by a file that does not match the cursor (for example a Tell whose sender timestamp is not newer than since_timestamp); and a status-only 245 reply when the parked long-poll expires without mail.
QMail Ping success response, bytes on TCP
+-------------------------------+-----------------------------------------+----------------------+
| RAIDA response header | encrypted tell-list payload | plaintext terminator |
| 32 bytes | payload_len bytes | 2 bytes |
+-------------------------------+-----------------------------------------+----------------------+
| status=250, group=6 | array header(8) | 3E 3E |
| body_size=payload_len+2 | record 1 | |
| challenge signature | record 2 | |
| | ... | |
+-------------------------------+-----------------------------------------+----------------------+
QMail Ping timeout response, stale-watcher path (15-16 minutes after parking)
+-------------------------------+----------------------+
| RAIDA response header | plaintext terminator |
| 32 bytes | 2 bytes |
+-------------------------------+----------------------+
| status=245, body_size=0 | 3E 3E |
| no encrypted payload | |
+-------------------------------+----------------------+
The stale-watcher sweep in net.c (cleanup_stale_ping_monitors, run every 60 seconds) sets STATUS_TIMEOUT (245, 0xF5) with output_size = 0 and hands the connection to prepare_response, which still appends the 3E 3E trailer. Earlier versions of this page described status 17; that is not what the server sends.
Response header (32 bytes, plaintext)
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | RAIDA ID | Responding RAIDA index. |
| 1 | 1 | Shard ID | Legacy response path writes zero. |
| 2 | 1 | Status | 0xFA on success (including an empty wake), 0xF5 (245) on long-poll expiry, or another error code. |
| 3 | 1 | Command Group | 0x06. |
| 4–5 | 2 | Frame count | Legacy response writes 00 01. |
| 6–7 | 2 | Echo bytes | Echoes legacy request bytes 30–31. |
| 8 | 1 | Reserved | Zero. |
| 9–11 | 3 | Body size | Big-endian length of encrypted payload plus terminator. Zero for timeout. |
| 12–15 | 4 | Execution time | Server execution time in microseconds, big-endian. |
| 16–31 | 16 | Challenge signature | Legacy response signature used by the client to verify the response belongs to the request. |
Tell-list payload on success (encrypted)
Decrypted tell-list payload
+----------------------+--------------------------------------+--------------------------------+
| Array header | Tell record 1 | Tell record 2 ... |
| 8 bytes | 64 + (M * 32) + manifest_len bytes | variable |
+----------------------+--------------------------------------+--------------------------------+
| Payload offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | tell_count | Number of tell records following this array header, as a single byte. The server casts its record count to this byte without a guard, so 256 records advertise as 0 and 257 as 1 while every record is still in the payload. A client must parse records until the payload is consumed and treat a mismatch between the byte and the records actually present as a truncated or wrapped batch, not as success. One or more when mail matched; zero on an empty wake. |
| 1–2 | 2 | total_tells | Zero-filled by the server today. The proposed compatible extension fills it with the number of Tells that matched the cursor so a client can detect a capped batch; see Wait (group 22, command 2). |
| 3–7 | 5 | reserved | Zero-filled. |
| 8... | Variable | tell records | tell_count records, each parsed using the record structure below. |
Tell record structure
Each returned record is the full contents of one .tell file. The server does not add a per-record length field. For manifest v1 records, the client reads three fields from the 64-byte file header to compute the record size:
stripe_count(byte 29, called M): number of server-location entries.file_count(byte 52, called N) andfile_entry_size(byte 53, always16): together describe the file manifest. The big-endianmanifest_lenat bytes 54–55 must equalN × file_entry_size.
manifest v1 record_size = 64 + (M * 32) + manifest_len
legacy v0 record_size = 64 + (M * 32), then optionally skip an 18-byte footer
+-------------------------+-----------------------------+--------------------------------+
| QMail file header | server location entries | file manifest entries |
| 64 bytes | M entries * 32 bytes | N entries * file_entry_size |
+-------------------------+-----------------------------+--------------------------------+
Current manifest v1 records do not include a trailing footer; the manifest entries are the last bytes of each record. Pre-manifest legacy records have manifest_version=0, no manifest entries, and may have an 18-byte recipient footer on disk (tag 0x50, length 16, recipient locker). A receiver that sees that footer after a legacy record should skip it before parsing the next record.
QMail file header (64 bytes)
| Record offset | Size | Field | Description |
|---|---|---|---|
| 0–15 | 16 | email_id | Message/file GUID. Used by download to fetch stored stripes. |
| 16–17 | 2 | sender_coin_id | 00 06. |
| 18 | 1 | sender_denomination | Sender mailbox denomination. |
| 19–22 | 4 | sender_serial_number | Sender mailbox serial number, big-endian. |
| 23 | 1 | sender_device_id | Device tag copied from the tell blob. Logged, not enforced. |
| 24–27 | 4 | timestamp | Sender timestamp, big-endian Unix seconds. |
| 28 | 1 | tell_type | Current QMail email tells use 0. |
| 29 | 1 | stripe_count (M) | Number of 32-byte server entries following this header. The tell sender validated this as 1–32. |
| 30–45 | 16 | locker_code | Locker/download key copied into the notification. The receiver passes this to download. |
| 46–49 | 4 | total_file_size | Big-endian original size of the body file only (file_type 0x01). Attachment original sizes live in the manifest entries. |
| 50 | 1 | version | 0x02. |
| 51 | 1 | manifest_version | 1 for current manifest records. 0 marks a legacy pre-manifest record. |
| 52 | 1 | file_count (N) | For manifest v1, number of files described by the manifest, including the body. Attachment count is N - 1. Legacy v0 records write 0. |
| 53 | 1 | file_entry_size | For manifest v1, always 16. Legacy v0 records write 0. |
| 54–55 | 2 | manifest_len | For manifest v1, big-endian byte length of the manifest trailing section. Must equal N × file_entry_size. Legacy v0 records write 0. |
| 56 | 1 | manifest_flags | For manifest v1, bit 0 (footer_removed) is set and bit 1 (crc32_present) is set when the per-entry CRC32 field is populated. Bits 2–7 are reserved. |
| 57–63 | 7 | reserved | Pass-through tail of the manifest header. Must be zero for manifest v1. |
Server location entry (32 bytes each)
There are M entries immediately after the file header. This structure tells the recipient which RAIDA servers hold the stripes and how to connect to them for download.
| Entry offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | stripe_index | Stripe number within the file’s stripe set. |
| 1 | 1 | stripe_type | Current rest_core writes 0 for data and 1 for parity. |
| 2 | 1 | server_id | RAIDA index when entry_kind=0; ignored for an independent server (entry_kind=1). |
| 3 | 1 | entry_kind | 0 = legacy RAIDA index: server_id at offset 2 identifies the server and bytes 4–9 are zero. 1 = independent server: server_id is ignored and bytes 4–9 identify the server. Any other value must be rejected. |
| 4–8 | 5 | identity | The server’s coin identity, present only when entry_kind = 1 (zero otherwise). Byte 4 is the denomination; bytes 5–8 are the serial number as a big-endian uint32. This is the same 5-byte packing RKE uses for client_sn, and these 5 bytes are also the first 5 bytes of the server’s derived cs_id, zero-padded to 16. |
| 9 | 1 | key_id | RKE key id to use with this server, present only when entry_kind = 1 (zero otherwise). Defaults to 1. |
| 10–19 | 10 | IPv6 prefix | Zero-filled for IPv4-mapped addresses. |
| 20–21 | 2 | IPv4 marker | FF FF for IPv4-mapped address. |
| 22–25 | 4 | IPv4 address | Storage RAIDA IPv4 octets. |
| 26–27 | 2 | Port | Storage RAIDA TCP port, big-endian. |
| 28–31 | 4 | reserved | Zero-filled by rest_core. |
File manifest entries (N × 16 bytes)
The manifest is the authoritative public object list for this email. New QMail beta records list private CBDF meta first with file_type=0x00, body/content second with file_type=0x01, and attachments afterward using sequential file types 0x0A, 0x0B, and so on. The receiver downloads file_type=0 first, then uses original_size when sizing each later reassembly buffer so padding bytes are trimmed correctly.
| Entry offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | file_type | 0x01 body, 0x0A attachment 1, 0x0B attachment 2, etc. First entry MUST be the body. |
| 1 | 1 | file_flags | Bit 0 = body; bit 1 = attachment; other bits reserved. |
| 2–3 | 2 | reserved | Pass-through; must be zero. |
| 4–11 | 8 | original_size | Big-endian uint64 size before striping/padding. The receiver uses this to size each file’s reassembly buffer. |
| 12–15 | 4 | crc32 | Big-endian CRC32 over the original file bytes. Optional: written only when the file header’s manifest_flags.crc32_present bit is set; zero otherwise. Receivers must only verify the CRC when the flag is set. |
Local REST test calls
These calls use the two-client local setup: client1 is the sender on port 8081 and client2 is the receiver on port 8082. Stopping the receiver background beacon is optional: with retained Tells both the beacon and this manual call would see the same notification, but stopping it keeps the log readable.
# Optional: stop client2 background beacon before the sender sends mail
GET http://127.0.0.1:8082/api/qmail/local/beacon/control?action=stop
# Long-poll client2's beacon RAIDA; notifications include manifest_version, file_count, manifest_flags, and files[]
GET http://127.0.0.1:8082/api/qmail/net/beacon/ping?timeout=10
# Download the received email and all manifest-listed attachments
GET http://127.0.0.1:8082/api/qmail/net/messages/download?file_guid=<FILE_GUID>
# Inspect downloaded attachment rows
GET http://127.0.0.1:8082/api/qmail/db/attachments/list?email_id=<FILE_GUID>
Long-poll mechanics
- Validate the request body length and
3E 3Eterminator. - Decrypt the body and validate the 16-byte challenge/CRC.
- Read the mailbox identity from the decrypted body and verify the AN.
- Read
since_timestampfrom body bytes 48–51 (0if the body is only 50 bytes). - Scan
QMAIL_MAILBOX_ROOT/{denom_hex}/{sn}/inbox/for.tellfiles in directory order. For each, read the 64-byte file header and keep the file if its sender timestamp (bytes 24–27) is strictly greater thansince_timestamp; a zero sender timestamp falls back to the file’sst_mtime. - If one or more match, copy them into the tell-list payload and send a success response. The buffer starts at 8 KiB and is doubled once per record that does not fit, up to 1 MiB; a record larger than the doubled size ends the scan (so a first record over about 16 KiB produces an empty result even though it matched). The count byte is a plain cast of the record count and wraps past 255. Nothing is deleted.
- If none match, create the inbox directory if needed, register a one-shot inotify watcher for this connection, set
STATE_PING_ACTIVE, and leave the TCP connection open. - When a
.tellfile is atomically renamed into the inbox, every parked connection watching that inbox (one per polling device) is dispatched toqmail_resume_ping(), which rescans with that connection’s ownsince_timestampand replies. The watcher is armed after the first scan, so a Tell that lands between the scan and the watch registration waits for the next poll. If the new file does not match the cursor the reply is a success withtell_count = 0; the client should simply ping again. - If no wake occurs, the once-a-minute sweep expires watchers older than 15 minutes and sends status
245with an empty body. The connection is then closed by the server. - The socket carries TCP keepalive (idle 60 s, interval 10 s, 5 probes), so a client that disappears while parked frees its watcher within about two minutes.
Status codes
| Decimal | Hex | Symbol | Meaning |
|---|---|---|---|
| 250 | 0xFA | STATUS_SUCCESS | Returned with the tell-list response when one or more tells were ready immediately or arrived during the long-poll. |
| 245 | 0xF5 | STATUS_TIMEOUT | The parked long-poll expired (15–16 minutes) with no matching Tell. Body size is 0; the 3E 3E trailer is still written. Treat as “no mail” and ping again. |
| 8 | 0x08 | ERROR_COIN_NOT_FOUND | Mailbox (denom, SN) from the body is not loaded on this RAIDA. |
| 16 | 0x10 | ERROR_INVALID_PACKET_LENGTH | Request body is shorter than the required 48 + 2 bytes. |
| 33 | 0x21 | ERROR_INVALID_EOF | Protocol terminator was not 3E 3E. |
| 34 | 0x22 | ERROR_INVALID_ENCRYPTION | Server could not decrypt/validate the body before reaching cmd_qmail_ping. |
| 37 | 0x25 | ERROR_INVALID_CRC | The decrypted challenge CRC did not match. |
| 194 | 0xC2 | ERROR_FILESYSTEM | Inbox opendir() failed for a reason other than ENOENT, or the inotify watch could not be armed. |
| 200 | 0xC8 | ERROR_INVALID_AN | Body AN does not match the server’s stored AN for the body denomination/SN. |
| 254 | 0xFE | ERROR_MEMORY_ALLOC | Server response buffer allocation failed. |
Client budget: the server holds a poll for up to 16 minutes, so a client socket timeout shorter than that expires first. rest_core’s default is 600 s. A client must either wait longer than the server or treat its own expiry on command 72 as “no mail” and re-ping at once; it must not count it as a failure. The proposed Wait (group 22, command 2) lets the client choose the hold time.
Common mistakes
Rejecting an empty success
Usually no mail means the connection is parked until mail arrives or the watcher times out. But a wake whose rescan finds nothing newer than the cursor returns STATUS_SUCCESS with tell_count = 0 immediately. A client that treats that as an error, or sleeps for minutes before pinging again, will miss the next notification window.
Confusing the packet header with qmail_preamble_t
The 32-byte RAIDA header is plaintext. The server’s 48-byte qmail_preamble_t is decrypted body data: challenge/CRC plus the QMail identity/auth block.
Sending since_timestamp = 0 on every ping
Tells are retained for 30 days, so a zero cursor returns the whole retained inbox on every wake and can hit the 1 MiB cap. Send the largest sender timestamp you have already persisted. Zero is only right for a brand-new mailbox.
Treating your own socket timeout as a failure
The server answers an idle poll only after 15–16 minutes. If your read timeout is shorter (rest_core: 600 s) the poll ends on your side first. That is the normal idle case, not an error: re-ping immediately and do not back off.
Computing record_size without reading the manifest header
Each record’s size depends on both stripe_count (byte 29) and manifest_len (bytes 54–55). A client that uses 64 + M*32 alone will land partway into the manifest of the first record and misparse every subsequent one. Always include manifest_len in the per-record advance.
Points of confusion
- ping vs peek. Both apply
since_timestampthe same way. The only difference is what happens when nothing matches: peek returns an empty list at once, ping parks the connection. - Tells are not deleted on read. Every device polling a mailbox sees every retained Tell. Deduplicate by
email_id(GUID) and persist before advancing the cursor. - The cursor is the sender’s clock. The filter compares
since_timestampwith the timestamp the sender wrote into the Tell header, not with server time. Two senders with skewed clocks can hide each other’s mail from a cursor that advances to the newest value seen; a client should keep a look-back margin, and Wait (group 22, command 2) replaces the timestamp with a server-ordered cursor. - Batches can be cut silently. The scan stops at 1 MiB in directory order, or earlier when one record is larger than the buffer can grow in a single doubling, and sets no flag;
total_tellsis zero and the count byte wraps. A client that advances its cursor to the newest timestamp in a cut batch can strand the Tells that were left out, and re-sending the same cursor returns the same prefix, so there is no client-side way to page through the remainder. Nearness to 1 MiB is not a reliable signal either. Treat a suspected cut as a stalled-discovery condition to surface, and see the proposed Wait (group 22, command 2) for an explicit continuation. - Records are variable length. There is no length prefix per record. Use
stripe_count(byte 29) andmanifest_len(bytes 54–55) from the file header to compute64 + M*32 + manifest_len. - Same-second tells. The filter is strictly greater on whole seconds. A second Tell stamped in the same second as the newest one you received, but indexed after your reply, will not be returned by a cursor equal to that second.