Save contact photos inline, and load cards so avatars show

Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.

Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.

Fixes #376.
This commit is contained in:
2026-09-16 11:27:47 -07:00
parent aa9bf1b9b2
commit d38dee7eb9
10 changed files with 150 additions and 18 deletions
+15
View File
@@ -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);
+17 -1
View File
@@ -434,7 +434,23 @@ export const handlers: Record<string, Handler> = {
* 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<string, Obj> | 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<string, { id: string }>).map((x) => x.id),