diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 00f4f8e..12c5cf9 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). +- **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)). + - **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards: - **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these. diff --git a/server/src/account.test.ts b/server/src/account.test.ts index 28a1b51..b563ef7 100644 --- a/server/src/account.test.ts +++ b/server/src/account.test.ts @@ -144,6 +144,21 @@ test("upstream caches let go of sessions that have aged out", async () => { assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 }); }); +test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => { + const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) }); + const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media }); + const res = await jmap([["ContactCard/set", { accountId: "a1", create: { + blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }), + inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }), + } }, "s"]]); + assert.equal(res.status, 200); + const set = res.body.methodResponses[0][1]; + assert.equal(set.notCreated.blob.description, "blobIds in media is not supported."); + assert.deepEqual(set.notCreated.blob.properties, ["media"]); + assert.ok(set.created.inline.id, "a data URI is accepted"); + await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]); +}); + test("an app password needs a name", async () => { const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" }); assert.equal(res.status, 400); diff --git a/server/src/mock/handlers.ts b/server/src/mock/handlers.ts index c7a637f..4013383 100644 --- a/server/src/mock/handlers.ts +++ b/server/src/mock/handlers.ts @@ -434,7 +434,23 @@ export const handlers: Record = { * state older than the log's window cannot be answered, as on a real server. */ "ContactCard/set": (a) => { - const r = genericSet(cards, "cc")(a); + /* + * Stalwart refuses a `blobId` inside `media` (0.16.22, checked live on + * 2026-09-16), and takes the whole call down for it. The mock took + * anything, which is how ihasmail shipped a photo upload that never + * worked against the real server (#376). + */ + const withBlobMedia = (o: unknown) => Object.values(((o as Obj)?.media as Record | null) ?? {}).some((m) => m && "blobId" in m); + const refuse = { type: "invalidProperties", description: "blobIds in media is not supported.", properties: ["media"] }; + const create = { ...((a.create as Obj) ?? {}) }; + const update = { ...((a.update as Obj) ?? {}) }; + const notCreated: Obj = {}; + const notUpdated: Obj = {}; + for (const [k, v] of Object.entries(create)) if (withBlobMedia(v)) { notCreated[k] = refuse; delete create[k]; } + for (const [k, v] of Object.entries(update)) if (withBlobMedia(v)) { notUpdated[k] = refuse; delete update[k]; } + const r = genericSet(cards, "cc")({ ...a, create, update }); + if (Object.keys(notCreated).length) r.notCreated = { ...((r.notCreated as Obj) ?? {}), ...notCreated }; + if (Object.keys(notUpdated).length) r.notUpdated = notUpdated; nextState(); recordCardChange({ created: Object.values((r.created ?? {}) as Record).map((x) => x.id), diff --git a/web/src/lib/__tests__/contacts.test.ts b/web/src/lib/__tests__/contacts.test.ts index 408f63a..6773c52 100644 --- a/web/src/lib/__tests__/contacts.test.ts +++ b/web/src/lib/__tests__/contacts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { contactFromAddress, nameParts } from "../contacts"; +import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts"; import type { ContactCard } from "@/jmap/types"; const parts = (name: string | null, email = "a@b.io") => @@ -34,3 +34,35 @@ describe("contactFromAddress", () => { expect(contactFromAddress({ name: " ", email: "ada@example.org" }).name).toBeUndefined(); }); }); + +/** + * #376: a photo saved as a `blobId` was refused by Stalwart, which only takes + * the `uri` form. Saving one must also leave a card's other media alone. + */ +describe("withPhoto", () => { + const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" }; + + it("puts the photo in as a data URI, never a blob id", () => { + const media = withPhoto(undefined, photo)!; + const [m] = Object.values(media); + expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" }); + expect(m).not.toHaveProperty("blobId"); + }); + + it("replaces an existing photo and keeps a logo", () => { + const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!; + expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1); + expect(media.old).toBeUndefined(); + expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" }); + }); + + it("removes only the photo, and clears media when nothing is left", () => { + expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } }); + expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull(); + }); + + it("is read back by contactPhoto", () => { + const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard; + expect(contactPhoto(card, "a1")).toBe(photo.dataUrl); + }); +}); diff --git a/web/src/lib/contacts.ts b/web/src/lib/contacts.ts index bc06dcc..c8773e7 100644 --- a/web/src/lib/contacts.ts +++ b/web/src/lib/contacts.ts @@ -1,4 +1,4 @@ -import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types"; +import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types"; import { withBase } from "@/lib/basePath"; /** Best display name for a card. */ @@ -57,6 +57,22 @@ export function contactEmails(c: ContactCard): EmailAddress[] { return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address })); } +/** + * A card's `media` with its photo replaced by `photo`, or removed when that is + * null, and everything else in it -- a logo, a sound -- left as it was. + * + * The photo goes in as a `data:` URI. Stalwart (0.16.22, checked live on + * 2026-09-16) refuses a `blobId` in `media` outright -- "blobIds in media is + * not supported" -- which is RFC 9610's JMAP extension to JSContact, and + * accepts the plain RFC 9553 `uri` form, returning it unchanged (#376). The + * editor's photo is a 256px JPEG, tens of kilobytes; 134 KB was accepted. + */ +export function withPhoto(media: Record | undefined | null, photo: { dataUrl: string; type: string } | null): Record | null { + const rest: Record = Object.fromEntries(Object.entries(media ?? {}).filter(([, m]) => m.kind !== "photo")); + if (photo) rest[newKey("p")] = { "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: photo.type }; + return Object.keys(rest).length ? rest : null; +} + export function contactPhoto(c: ContactCard, accountId: string): string | null { const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo"); if (!m) return null; diff --git a/web/src/store/__tests__/contacts-cards-at-start.test.ts b/web/src/store/__tests__/contacts-cards-at-start.test.ts new file mode 100644 index 0000000..0383499 --- /dev/null +++ b/web/src/store/__tests__/contacts-cards-at-start.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { useSession } from "@/store/session"; +import { useContacts } from "@/store/contacts"; + +/** + * #376: avatars in the mail list come from the address book's cards, and + * nothing loaded those at sign-in -- so a contact's photo showed once Contacts + * had been opened and was gone after the next reload. + */ + +const session = { + capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.contacts]: {} }, + accounts: { own: { name: "me@example.com", isPersonal: true, accountCapabilities: { [CAP.contacts]: {} } } }, + primaryAccounts: { [CAP.contacts]: "own" }, + state: "s", +} as unknown as JmapSession; + +beforeEach(() => { + client.session = session; + useSession.setState({ status: "authenticated", session, accountId: "own" }); + useContacts.setState({ accountId: null, loaded: false, loading: false, cards: {}, cardState: null }); + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + const methodResponses = methodCalls.map(([name, , id]) => { + if (name === "ContactCard/query") return [name, { ids: ["c1"], total: 1, position: 0, queryState: "q" }, id]; + if (name === "ContactCard/get") return [name, { state: "5", list: [{ id: "c1", emails: { e: { address: "ann@example.com" } }, media: { p: { kind: "photo", uri: "data:image/jpeg;base64,AA" } } }], notFound: [] }, id]; + return [name, { state: "1", list: [], notFound: [] }, id]; + }); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response; + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("contacts at sign-in", () => { + it("loads the cards, so an avatar can be found without opening Contacts", async () => { + await useContacts.getState().init(); + await vi.waitFor(() => expect(useContacts.getState().loaded).toBe(true)); + const card = useContacts.getState().lookupByEmail("ann@example.com"); + expect(card?.id).toBe("c1"); + }); +}); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index 853b971..2b8bfab 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -277,6 +277,13 @@ export const useContacts = create((set, get) => ({ if (!available) return; await get().loadBooks(); void get().loadShared(); + /* + * The cards too, in the background. The avatars in the mail list come from + * them, and nothing else loaded them until Contacts was opened or an + * address was typed -- so a photo appeared once somebody did either, and + * was gone again after the next reload (#376). + */ + if (!get().loaded) void get().loadAll(); }, /* diff --git a/web/src/ui/misc.tsx b/web/src/ui/misc.tsx index 46c68a7..813bca8 100644 --- a/web/src/ui/misc.tsx +++ b/web/src/ui/misc.tsx @@ -8,10 +8,13 @@ import { t } from "@/lib/i18n"; export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) { const email = typeof who === "string" ? who : (who?.email ?? ""); const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? ""); + // Whatever cards are held count, the reader's own or a shared book's; the + // photo is fetched from the account the card belongs to. const photo = useContacts((s) => { - if (!email || !s.loaded) return null; + if (!email) return null; const c = s.lookupByEmail(email); - return c && s.accountId ? contactPhoto(c, s.accountId) : null; + const account = c ? (s.accountOfCard(c.id) ?? s.accountId) : null; + return c && account ? contactPhoto(c, account) : null; }); return (