Advertise byte ranges on downloads, and record the live checks
Stalwart honors a single byte range on its download endpoint but sends no Accept-Ranges, and Chrome's PDF viewer only reads a file in pieces when the first response says it can. The proxy now says so itself. Checked live on 0.16.22: ContactCard/changes reports creates, updates and destroys exactly, which the contacts store's sync relies on, and a range the server cannot serve gets the whole file with 200, never 416. The mock now answers ranges the same way and sends no Accept-Ranges.
This commit is contained in:
@@ -48,6 +48,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
|
||||
|
||||
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
|
||||
|
||||
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
||||
|
||||
@@ -127,9 +127,12 @@ test("a download passes a byte range through, for viewers that read in pieces",
|
||||
assert.equal(await part.text(), "hello");
|
||||
const whole = await app.request(url, { headers: { cookie } });
|
||||
assert.equal(whole.status, 200);
|
||||
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
|
||||
assert.equal(await whole.text(), "hello world");
|
||||
// Past the end, Stalwart sends the whole file rather than a 416.
|
||||
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
|
||||
assert.equal(beyond.status, 416);
|
||||
assert.equal(beyond.status, 200);
|
||||
assert.equal(await beyond.text(), "hello world");
|
||||
// Anything that is not a plain byte range is not passed on.
|
||||
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
|
||||
assert.equal(odd.status, 200);
|
||||
|
||||
+8
-1
@@ -822,7 +822,14 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const partial = res.status === 206 && res.headers.get("content-range");
|
||||
if (partial) headers.set("Content-Range", partial);
|
||||
if (res.headers.get("accept-ranges") === "bytes") headers.set("Accept-Ranges", "bytes");
|
||||
/*
|
||||
* Said here because Stalwart does not say it. It honors a single byte
|
||||
* range but sends no `Accept-Ranges` (0.16.22, checked live on
|
||||
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
|
||||
* the first response advertises it. A server that ignores a range sends
|
||||
* the whole file, which the browser takes just as well.
|
||||
*/
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
|
||||
@@ -139,20 +139,23 @@ export const server = createServer(async (req, res) => {
|
||||
const b = blobs.get(blobId ?? "");
|
||||
if (!b) { res.writeHead(404); return res.end(); }
|
||||
const type = url.searchParams.get("accept") ?? b.type;
|
||||
// One byte range, the way a PDF viewer or a video element asks for one.
|
||||
/*
|
||||
* One byte range, answered as Stalwart answers it (0.16.22, checked live
|
||||
* on 2026-09-16): a 206 for a single range it can serve, and the whole
|
||||
* file with a 200 for anything else -- several ranges, or one past the
|
||||
* end. It never sends Accept-Ranges.
|
||||
*/
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
|
||||
if (m && (m[1] || m[2])) {
|
||||
const size = b.data.length;
|
||||
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
|
||||
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
||||
if (start >= size || start > end) {
|
||||
res.writeHead(416, { "content-range": `bytes */${size}` });
|
||||
return res.end();
|
||||
}
|
||||
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" });
|
||||
if (start < size && start <= end) {
|
||||
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}` });
|
||||
return res.end(b.data.subarray(start, end + 1));
|
||||
}
|
||||
res.writeHead(200, { "content-type": type, "content-length": b.data.length, "accept-ranges": "bytes" });
|
||||
}
|
||||
res.writeHead(200, { "content-type": type, "content-length": b.data.length });
|
||||
return res.end(b.data);
|
||||
}
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user