QMail Download — Group 6, Code 74

Fetches one 256 KB page of one stored file from a storage RAIDA. The recipient calls download in parallel against each storage RAIDA listed in the tell’s notification blob, then reassembles the file locally.

Quick reference

Command Group6
Command Code74
Server functioncmd_qmail_download in cmd_qmail.c
Wire structureqmail_download_req_t (request), qmail_download_resp_t (response header)
Body layoutPreamble (48) + Payload (37) + Terminator (2)
Response body8-byte header + up to 256 KB of stripe data
EncryptionRequired — AES-128 keyed by recipient’s coin AN
Page sizeFixed at 256 KB by the server (page_size = 256 * 1024)
File-size capQMAIL_MAX_STRIPE_SIZE = 10 MiB

Purpose

After receiving a tell notification, the recipient knows the email’s GUID, the locker code that was written into the file, and the list of storage RAIDAs holding stripes. The recipient issues one download per stripe (in parallel) to the listed RAIDAs, providing the GUID, the locker code (for authenticated download), the file_type (which file inside the email to fetch), and a page number.

For new QMail beta messages, the receiver downloads file_type=0 first. That private CBDF meta object contains subject, preview text, attachment names, and the private object map needed to drive the rest of the download. The public Tell envelope deliberately omits those private display fields.

Compatibility paging

Large attachments uploaded with command 75 are still downloaded by this command, command 74. The server must support two storage layouts under the same request/response format: legacy command-70 single files and command-75 per-page files. Commands 76 through 84 are assigned to the separate Object Transfer v1 subsystem.

Phase I of the QMail rollout uses ACL sidecars in “allow all” mode — the server checks for the .acl file’s presence but does not enforce per-recipient permissions. Future phases will restrict download to authorized recipients.

Request body

Preamble (48 bytes, offsets 0–47)

Standard QMail preamble. See QMail Overview — Universal preamble.

Command payload (37 bytes, offsets 48–84)

File GUID (16 bytes, part 1 of 2) 48 55 File GUID (part 2 of 2) 56 63 Locker Code (16 bytes, part 1 of 2) 64 71 Locker Code (part 2 of 2) 72 79 FT 80 BPP 81 PN (Page Number, BE) 82 84
OffsetSizeFieldDescription
48–6316file_guid16-byte email GUID. Same value the sender used in upload and tell.
64–7916locker_codeASCII locker key, null-padded to 16 bytes. Provided for symmetry with upload; the server reads it but does not currently enforce a payment for download (Phase I).
801file_typePicks which file inside the email to fetch. Same encoding as upload: 0=meta, 1=qmail/body, 0x0A..0xFE=attachments. Server selects the on-disk file by suffix.
811bytes_per_pageReserved. Server hard-codes 256 KB pages and ignores this byte. Available for protocol extension.
82–843page_number3-byte big-endian page index. Page 0 is the first 256 KB. Files larger than 256 KB require multiple download calls with incrementing page numbers.
last 22TerminatorFixed 3E 3E.

Total body length: exactly 48 + 37 + 2 = 87 bytes.

Response body

On success, the response carries an 8-byte header followed by up to 256 KB of stripe data. Layout matches qmail_download_resp_t:

OffsetSizeFieldDescription
01file_typeEcho of the request’s file_type.
11versionAlways 0x02.
21bytes_per_pageAlways 0. Reserved by the server.
31page_numberEcho of the request’s page_number (low byte only — truncated from the 3-byte request field).
4–74data_lengthBig-endian byte count of the stripe data that follows. Up to 262144 (256 KB).
8…NdataRaw stripe bytes. The recipient assembles N stripes into a complete file.

The last page of a file may be shorter than 256 KB — data_length reports the actual byte count.

Status codes

DecimalHexSymbolMeaning
2500xFASTATUS_SUCCESSPage returned in the response body.
160x10ERROR_INVALID_PACKET_LENGTHBody shorter than 87 bytes or missing terminator.
340x22ERROR_INVALID_ENCRYPTIONWire-level decryption failed before the command body was reached. Most often caused by the client building the request with a zero-AN preamble (e.g. an aliasing bug in the encryption-key fill path).
1940xC2ERROR_FILESYSTEMServer failed the path-traversal check or could not open the file for reading.
1980xC6ERROR_INVALID_PARAMETERFile on disk exceeds QMAIL_MAX_STRIPE_SIZE (10 MiB), or page_number * 256KB >= total_size (page is past the end of the file).
2020xCAERROR_FILE_NOT_EXISTNo file matching 00000000{GUID}.{suffix} exists at the GUID-sharded path. Either the GUID is wrong, the file_type is wrong (and the corresponding suffix wasn’t uploaded), or the upload was never delivered to this RAIDA.
2540xFEERROR_MEMORY_ALLOCServer allocation of the 8 + page_size output buffer failed.

Note: download does not currently authenticate the AN against a stored coin record. The preamble carries an AN field that the executor uses for wire encryption, but the handler doesn’t look up a page or compare the AN. Authorization is gated by ACL sidecar — today “allow all”.

Pagination

For legacy command-70 files larger than 256 KB the recipient calls download repeatedly, incrementing page_number until the response’s data_length is less than 256 KB (the last page) or the call returns ERROR_INVALID_PARAMETER (page past end).

page_number = 0
while True:
    resp = download(guid, locker, file_type, page_number)
    if resp.status == ERROR_INVALID_PARAMETER:
        break  // past end of file
    if resp.status != STATUS_SUCCESS:
        raise
    yield resp.data            // 0..256KB chunk
    if resp.data_length < 256*1024:
        break                  // last page
    page_number += 1

Most stripes are smaller than 256 KB and need only one call. Pagination is mainly for long-form attachments (file_type=0x0A and up).

Downloading cmd-75 paged objects

Files uploaded with QMail Upload Large Page (cmd 75) are stored as one file per page, not as a single stripe file. download (cmd 74) serves both layouts through the same request and response format. Only the server’s on-disk lookup differs. Object Transfer v1 commands 76 through 84 use their own range-based protocol.

Server lookup order (per-page-file first, then legacy)

For each download request the server resolves the object in this order:

  1. Per-page file (cmd 75): look for 00000000{GUID}{suffix}.p{PAGE}, where {PAGE} is the request page_number formatted as a 5-digit zero-padded decimal. If it exists, return that whole file as the page (data_length = the file’s size, always ≤ 256 KB). Here page_number selects which page file; it is not a byte offset.
  2. Legacy single file (cmd 70): otherwise open 00000000{GUID}{suffix} and return the 256 KB window at byte offset page_number × 256 KB (the behavior described above).
  3. If neither exists, return ERROR_FILE_NOT_EXIST (202).

The {suffix} mapping is identical to upload: 0.meta, 1.qmail, 10+.{type-10}.bin (so attachment 1 = .0.bin), else .blob. Examples: private meta page 0 = 00000000{GUID}.meta.p00000; body page 0 = 00000000{GUID}.qmail.p00000; attachment 1 page 3 = 00000000{GUID}.0.bin.p00003.

Because both layouts return “the Nth ≤256 KB chunk of the logical stripe,” command 74 can serve both storage formats. The recipient uses the Tell manifest and private CBDF meta to decide which file types exist and whether an object is paged.

Page number field

The request page_number is a 24-bit big-endian field (offsets 82–84). For cmd-75 objects, Phase 1 uses pages 0..65535 only — this matches the 16-bit page number that cmd 75 upload carries in request-header bytes 14–15 (and echoes inside its encrypted body). The high byte of the 24-bit field is reserved and must be zero in Phase 1; it is intentional headroom for a future widening, not a bug. (See also Points of confusion #2 on the 1-byte response echo — harmless within Phase-1 page counts.)

Status codes (cmd-75 objects)

Same codes as the table above, with one semantic to note: a missing page of a paged object returns ERROR_FILE_NOT_EXIST (202), not ERROR_INVALID_PARAMETER. With the per-page layout there is no “read past end of one file” condition; an absent page simply means that page was never stored on this RAIDA. The recipient should treat 202 for a page it expected as “this page is missing here” (resend on upload, or recover from other RAIDAs’ stripes), determining the expected page set from the manifest rather than by probing until an error.

Receiver guidance (client-side, provided by the rest_core / Codex side)

The server is dumb: it does not track how many pages an object has. The recipient determines the per-object page count from the encrypted CBDF manifest, not from a server field:

  • Paged-vs-legacy is signaled by CBDF key 4 in attachment order: value 0 means legacy command-70 storage; value > 0 marks a command-75 paged attachment. The key-4 value is a best-effort sender estimate.
  • Authoritative page count for a paged attachment is derived from original_size, the data-stripe count, and the 256 KB page size — e.g. pages = ceil(original_size / (data_stripe_count * 256 KB)). The server does not store or validate total-pages.
  • Integrity: the sender stores the attachment’s whole-file CRC/hash in the CBDF manifest; the recipient verifies the reassembled attachment against it after trimming to original_size.

Key 4 is not authoritative

CBDF key 4 is used to mark a paged attachment and preserve attachment-order mapping. The receiver derives the actual download loop bound from the Tell manifest’s 64-bit original_size, the Tell server list, and the fixed 256 KB page size. The server behavior above is unaffected either way.

Idempotency and conflict handling are defined on the upload side: re-uploading the same page with identical bytes is success; the same GUID + file_type + page with different bytes is rejected (status 198), so the page a recipient downloads is stable.

Manifest-driven download loop

A tell record carries a file manifest with one entry per stored file (private meta + body + N attachments). The recipient reads the manifest from any successfully decrypted ping/peek record, downloads file_type=0 first, and then issues parallel download calls for the body and attachments described by the private meta. Each entry supplies the inputs the recipient needs:

  • file_type selects which on-disk file to fetch. Mapping: 0x00.meta (private CBDF meta), 0x01.qmail (body/content), 0x0A.0.bin (attachment 1), 0x0B.1.bin (attachment 2), and so on (0x0A + k.{k}.bin). The manifest, not stripe_type, tells the receiver which file types exist.
  • original_size sizes the final output and helps bound the pagination loop. For legacy command-70 objects, the recipient reads 256 KB windows until the stripe ends. For command-75 paged attachments, it computes ceil(original_size / (data_stripe_count * 256KB)) and reassembles each page independently.
  • crc32 is verified end-to-end against the reassembled file only when the file header’s manifest_flags.crc32_present bit is set. When the flag is clear, the crc32 field is zero and must not be checked.
meta_entry = manifest.require(file_type=0x00)
private_meta = download_object(meta_entry)
parse_cbdf_meta(private_meta)

for entry in tell_record.manifest:                # skip meta; it is already downloaded
    if entry.file_type == 0x00:
        continue
    file_type    = entry.file_type
    target_bytes = entry.original_size
    expected_crc = entry.crc32 if manifest_flags.crc32_present else None

    if cbdf_key4_for_attachment(file_type) > 0:
        total_pages = ceil(target_bytes / (data_stripe_count * 256*1024))
        reassembled = stream_cmd75_pages(guid, locker, file_type, total_pages)
    else:
        reassembled = download_legacy_cmd70_object(guid, locker, file_type, target_bytes)

    if expected_crc is not None:
        assert crc32(reassembled) == expected_crc
    store(file_type, reassembled)

Per-file stripe reassembly (Reed-Solomon recovery) happens before the CRC check: download is called against each storage RAIDA in parallel, the recipient assembles the file from the surviving stripes, then verifies the optional CRC against the trimmed result.

Local REST test calls

These calls use client2 as the receiver on port 8082. Run a peek or ping first so client2 has a pending Tell with manifest data in its local DB.

# Consume and persist a Tell first if needed
GET http://127.0.0.1:8082/api/qmail/net/beacon/peek?since=0

# Download body plus every manifest-listed attachment
GET http://127.0.0.1:8082/api/qmail/net/messages/download?file_guid=<FILE_GUID>

# List stored attachment rows; each row includes file_type and size_bytes
GET http://127.0.0.1:8082/api/qmail/db/attachments/list?email_id=<FILE_GUID>

# Return JSON metadata for one attachment, then fetch the binary without info=1
GET http://127.0.0.1:8082/api/qmail/db/attachments/get?email_id=<FILE_GUID>&attachment_id=<ATTACHMENT_ID>&info=1
GET http://127.0.0.1:8082/api/qmail/db/attachments/get?email_id=<FILE_GUID>&attachment_id=<ATTACHMENT_ID>

Common mistakes

Wrong AN in preamble

Although download doesn’t currently look up the coin to compare ANs, the preamble’s AN is still used to derive the wire-encryption key. If the client puts a zero-AN in the preamble (a common bug when an encryption-state struct is unsafely aliased), the server cannot decrypt the body, never reaches the handler, and returns ERROR_INVALID_ENCRYPTION with user:0/0 in the access log.

Asking for the wrong file_type

An email’s body is at file_type=1; attachments start at file_type=0x0A. If the sender uploaded only the body and the recipient requests file_type=0x0A, the server returns ERROR_FILE_NOT_EXIST — not because the email is missing, but because no attachment file was ever stored under that suffix.

Treating “0 of 8 stripes” as a network problem

If every parallel download returns ERROR_INVALID_ENCRYPTION, the recipient’s reassembly code reports “Insufficient stripes: 0/8 downloaded”. That looks like a network failure but is actually a wire-encryption failure on every call. Inspect the server log for status 34 entries before suspecting the network.

Using the locker_code field as a payment

The locker_code field exists for symmetry with upload, where it represents storage payment. download doesn’t currently consume it — the server reads the field but ignores it. Don’t expect the field to gate access; that role is intended for the ACL sidecar in future phases.

Points of confusion

  1. Preamble AN is not authenticated against a coin. Unlike upload, tell, ping, and peek, download’s handler does not call get_page_by_sn_lock + AN comparison. The preamble’s AN is consumed by the wire-encryption layer and that’s the end of it. So download currently has no per-coin authorization — only path-correctness and ACL-sidecar presence.
  2. Response page_number is one byte, request page_number is three. The request physically carries three bytes, but Phase 1 clients send the high byte as zero and enforce the shared 16-bit page cap. The response truncates to the low byte. Current rest_core does not use the echoed page number to drive reassembly; it trusts the request loop and data_length. If a future phase uses more than 255 pages per object and wants to validate the echo, the response field must be widened or ignored explicitly.
  3. The 1-byte bytes_per_page field is reserved. The server uses a fixed 256 KB page size and ignores this byte. Available for protocol extension — e.g., a future client could request smaller pages for low-bandwidth links.
  4. ACL is presence-only. The server checks access(acl_path, F_OK). If the sidecar is missing the access is logged as “allowing (legacy file)” and the download proceeds. If the sidecar is present its contents are not read in Phase I.
  5. Total file size is read at the time of the call. The server fseeks to end and reads ftell() on every page. Truncation, deletion, or pruning between page calls returns ERROR_INVALID_PARAMETER if the read offset moves past end.