diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 81ddf0e..58d23c1 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -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)). diff --git a/server/src/account.test.ts b/server/src/account.test.ts index b563ef7..cee4e04 100644 --- a/server/src/account.test.ts +++ b/server/src/account.test.ts @@ -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); diff --git a/server/src/app.ts b/server/src/app.ts index c8f6e89..7e7e73e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -822,7 +822,14 @@ export function createApp(basePath = config.basePath): Hono { 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", diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index bff19bb..9467e8d 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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(); + 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(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" }); - 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); } /*