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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user