Files
native/src/lib/contact-media.ts
T
Linus Rath 00619751d7 fix: never send a rescheduled message twice and handle partial refusals
"Send now" and "Reschedule" at a full submission quota (Stalwart 1.0)
made room and then created the replacement again. Making room can take
a while - a prune, a walk through the change log - and the original
could go out meanwhile, so the replacement sent it a second time, with
a HOLDFOR that still counted from before the wait. The original is now
read again first: nothing is resubmitted unless it is still pending
("This message was already sent" or "no longer scheduled" otherwise,
and the undo bar lets go of it), a failed read changes nothing, and the
hold is worked out again from the time the user picked. Port of the
webmail's 65eb4b10.

- the Stalwart explanation of a full quota (Bulwark removed what it
  could, an administrator has to raise the limit) needs
  `urn:stalwart:jmap` on the submission account; another server's
  `overQuota` gets a generic sentence with its own description
- when making room finds nothing that can go, the account is not
  searched again for five minutes; each send, reschedule and automatic
  read receipt at a full quota used to run the prune, the change-log
  walk and the gets again
- unsubscribing by mail at a full quota explains the quota instead of
  only saying "Unable to unsubscribe"
- push on Stalwart 1.0 (`principals:owner` on the account) takes the
  emailpush capability as the answer to which accounts notify: a group
  with only mail no longer counts as unreachable, and a refusal
  remembered under 0.16 no longer keeps a group out of the map
- a push re-registration the user asked for at the server's
  subscription limit removes the old record first and tries once more;
  the old record counted against the limit, so it could never be
  replaced
- "Mark folder as read" on a folder and its subfolders goes on past a
  folder the server refuses and reports the refusal at the end; the
  folders and the list refresh either way
- marking, starring or tagging a selection writes every account before
  reporting a refusal and shows only the messages the server updated;
  one refusing account or message used to leave the rest unwritten,
  and what was written looked unchanged
- a new photo for a contact opened from the cache is not saved when the
  card's media can't be read from the server either; it would have
  replaced the card's other media (a logo, more photos)
2026-09-25 23:51:39 +02:00

338 lines
14 KiB
TypeScript

// Contact media that live as blobs, and writing a newly picked photo. Port
// of the webmail's lib/contact-media.ts.
//
// Stalwart 1.0 returns every embedded PHOTO, LOGO and SOUND as
// `{kind, blobId, mediaType}`; 0.16 returns data: URIs and refuses a blobId in
// `media` ("blobIds in media is not supported.").
import { getBlobsAsBase64, uploadBytes } from '../api/blob';
import { getContactsAccountId } from '../api/contacts';
import { jmapClient } from '../api/jmap-client';
import { JMAPMethodError, jmapErrorProperties } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { ContactCard, ContactMedia } from '../api/types';
import { findContactPhotoEntry } from './contact-utils';
import { bytesToBase64, dataUriToBytes } from './data-uri';
/** What the user did to the photo in the contact form. */
export type PhotoEdit =
| { kind: 'unchanged' }
| { kind: 'new'; uri: string; mediaType: string }
| { kind: 'removed' };
/** Options for saving a card from the contact form. */
export interface ContactSaveOptions {
/** Key of the `media` entry holding a photo the user just picked, as a data: URI. */
photoKey?: string;
}
type BlobMedia = ContactMedia & { blobId: string };
function isBlobOnly(entry: ContactMedia | null | undefined): entry is BlobMedia {
return !!entry?.blobId && !entry.uri;
}
export function hasBlobMedia(media: Record<string, ContactMedia> | null | undefined): boolean {
return Object.values(media ?? {}).some(isBlobOnly);
}
/** The account a card's blobs live in: the owner's for a shared book. */
function mediaAccountId(contact: Pick<ContactCard, 'isShared' | 'accountId'>): string {
return contact.isShared && contact.accountId ? contact.accountId : getContactsAccountId();
}
/** A `media` key for a new photo that no other entry uses. */
function freeMediaKey(media: Record<string, ContactMedia>): string {
let key = 'photo';
for (let n = 1; key in media; n++) key = `photo${n}`;
return key;
}
/**
* The `media` map to send for a photo edit, built on `base`: the card's media
* as the server has it now when it could be read, else the form's copy
* (`known`). `shownKey` is the photo the form showed.
*
* - A new photo takes the shown one's key and place, with its `pref`,
* `label` and `contexts`, so on a card with several photos it is still the
* one shown (the lowest pref wins).
* - Removing the photo removes every photo the form's copy had, not only the
* shown one: the next would otherwise take its place after the save, while
* the form showed none. Logos and sounds stay; so do photos added elsewhere
* since.
*
* The other entries go back as the server returned them: a data: URI on 0.16,
* a blobId on 1.0, which resolves it again. A map replaces every entry on the
* server, and `null` (nothing left) deletes them all.
*/
export function mediaForPhotoEdit(
base: Record<string, ContactMedia>,
known: Record<string, ContactMedia>,
shownKey: string | undefined,
edit: Exclude<PhotoEdit, { kind: 'unchanged' }>,
): { media: Record<string, ContactMedia> | null; photoKey?: string } {
if (edit.kind === 'removed') {
const others = Object.fromEntries(Object.entries(base).filter(
([key, entry]) => !(entry?.kind === 'photo' && known[key]?.kind === 'photo'),
));
return { media: Object.keys(others).length > 0 ? others : null };
}
const shown = shownKey ? (base[shownKey]?.kind === 'photo' ? base[shownKey] : known[shownKey]) : undefined;
const photoKey = shownKey && (!base[shownKey] || base[shownKey]?.kind === 'photo') ? shownKey : freeMediaKey(base);
const photo: ContactMedia = {
kind: 'photo',
uri: edit.uri,
mediaType: edit.mediaType,
...(shown?.pref !== undefined ? { pref: shown.pref } : {}),
...(shown?.label !== undefined ? { label: shown.label } : {}),
...(shown?.contexts !== undefined ? { contexts: shown.contexts } : {}),
};
const media: Record<string, ContactMedia> = {};
for (const [key, entry] of Object.entries(base)) media[key] = key === photoKey ? photo : entry;
media[photoKey] = photo;
return { media, photoKey };
}
/**
* A new photo for a card on the server that neither the form nor a fresh read
* knows the media of: a `media` map built from nothing would delete the
* card's other entries - a logo, other photos - since a map replaces them all.
*/
export class ContactMediaUnreadError extends Error {
constructor() {
super("The contact's photos could not be read");
this.name = 'ContactMediaUnreadError';
}
}
/**
* What the contact form saves in `media`, and the key of a newly picked
* photo; null when `media` stays out of the save, which keeps every entry on
* the server (an unchanged photo).
*
* `known` is the card's media as the form got it, `current` as the server has
* it now (null when it couldn't be read, or for a new card). The contacts
* cache is stored without media, so a card opened from it before a refresh
* knows none: a new photo then replaces the photo the server has, and
* removing one the form never showed changes nothing. When the server's copy
* of such a card (`onServer`) couldn't be read either, a new photo throws
* ContactMediaUnreadError instead of replacing every entry.
*/
export function mediaForFormSave(
known: Record<string, ContactMedia> | undefined,
current: Pick<ContactCard, 'media'> | null,
edit: PhotoEdit,
onServer = false,
): { media: Record<string, ContactMedia> | null; photoKey?: string } | null {
if (edit.kind === 'unchanged') return null;
if (edit.kind === 'new' && onServer && known === undefined && current === null) throw new ContactMediaUnreadError();
const formCopy = edit.kind === 'new' ? known ?? current?.media ?? {} : known ?? {};
const shownKey = findContactPhotoEntry({ media: formCopy })?.[0];
if (edit.kind === 'removed' && !shownKey) return null;
return mediaForPhotoEdit(current ? current.media ?? {} : formCopy, formCopy, shownKey, edit);
}
// Accounts (per server) whose ContactCard/set refused a blobId in media
// although the blob capability suggested it takes one.
const blobMediaRefused = new Set<string>();
function refusalKey(accountId: string): string {
return `${jmapClient.serverUrl ?? ''}|${accountId}`;
}
/**
* Whether ContactCard/set in the account takes a `media` entry that carries
* a blobId. Stalwart 1.0 embeds contact media as blobs and lists ContactCard
* among the blob capability's supportedTypeNames (RFC 9404); 0.16 lists only
* Email, Thread and SieveScript, and refuses such an entry. A refusal seen at
* runtime wins for the rest of the session.
*/
export function supportsContactBlobMedia(accountId: string): boolean {
if (blobMediaRefused.has(refusalKey(accountId))) return false;
const blob = jmapClient.getAccountCapability(CAPABILITIES.BLOB, accountId) as
| { supportedTypeNames?: unknown }
| undefined;
return Array.isArray(blob?.supportedTypeNames) && blob.supportedTypeNames.includes('ContactCard');
}
/**
* The server refused a blobId inside `media`: invalidProperties naming
* `media` or an entry below it. 0.16 answers so ("blobIds in media is not
* supported.", properties `["media"]`). 1.0's "The following blobIds do not
* exist or are not accessible" names no property and is a different problem.
*/
export function isBlobMediaRejection(error: unknown): boolean {
return error instanceof JMAPMethodError && error.type === 'invalidProperties'
&& jmapErrorProperties(error).some((p) => p === 'media' || p.startsWith('media/'));
}
/**
* Saves a card whose `media[photoKey]` is a photo the user just picked, as a
* data: URI, through `write` (a ContactCard/set create or update in
* `accountId`, the contacts account when undefined).
*
* Where the account's cards carry blobs (supportsContactBlobMedia), the photo
* is uploaded and sent as `{kind, blobId, mediaType}`. The upload goes into
* the user's own account whichever account the card is written to: Stalwart
* 1.0 resolves an uploaded blob only for members of the account it was
* uploaded to, so one uploaded into the owner's account of a shared address
* book would not resolve for a sharee, while the user's own always does.
*
* Otherwise, and when the upload fails, the data: URI goes out as it is. If
* the server still refuses the blobId the way 0.16 does, the same write is
* sent once more with the data: URI, and the account gets no more uploads for
* the rest of the session.
*/
export async function writeContactWithNewPhoto<T>(
accountId: string | undefined,
card: Partial<ContactCard>,
photoKey: string,
write: (card: Partial<ContactCard>) => Promise<T>,
): Promise<T> {
const photo = card.media?.[photoKey];
const targetAccountId = accountId || getContactsAccountId();
if (!photo?.uri || !supportsContactBlobMedia(targetAccountId)) return write(card);
const decoded = dataUriToBytes(photo.uri, photo.mediaType);
if (!decoded) return write(card);
let blobId: string;
try {
({ blobId } = await uploadBytes(decoded.bytes, decoded.type, getContactsAccountId()));
} catch (err) {
console.warn('[contacts] photo upload failed, saving it inline', err);
return write(card);
}
const { uri: _uri, ...entry } = photo;
const withBlob: ContactMedia = { ...entry, mediaType: photo.mediaType ?? decoded.type, blobId };
try {
return await write({ ...card, media: { ...card.media, [photoKey]: withBlob } });
} catch (err) {
if (!isBlobMediaRejection(err)) throw err;
blobMediaRefused.add(refusalKey(targetAccountId));
return write(card);
}
}
/** `media` with each blob entry replaced by a data: URI from `uriOf`, or dropped/kept when it has none. */
async function withInlineBlobs(
media: Record<string, ContactMedia>,
uriOf: (entry: BlobMedia) => Promise<string | null>,
onFailure: 'drop' | 'keep',
): Promise<Record<string, ContactMedia>> {
const out: Record<string, ContactMedia> = {};
// One entry at a time: a card has a few, and each may be a download.
for (const [key, entry] of Object.entries(media)) {
if (!entry?.blobId) {
out[key] = entry;
continue;
}
const { blobId: _blobId, ...rest } = entry;
if (entry.uri) {
out[key] = rest;
continue;
}
const uri = await uriOf(entry as BlobMedia);
if (uri) out[key] = { ...rest, uri };
else if (onFailure === 'keep') out[key] = entry;
}
return out;
}
async function downloadAsDataUri(entry: BlobMedia, accountId: string): Promise<string | null> {
try {
const type = entry.mediaType || 'application/octet-stream';
const buffer = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.kind, type, accountId);
return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`;
} catch (err) {
console.warn('[contacts] media download failed', err);
return null;
}
}
/**
* `media` of `contact` with every blob entry turned into a data: URI of its
* bytes, for a copy of the card written elsewhere (duplicate, move to another
* account), which cannot count on the source card's blobId resolving. An
* entry whose download fails is dropped, or with `onFailure: 'keep'` sent on
* as it is.
*/
export async function inlineContactMedia(
contact: Pick<ContactCard, 'media' | 'isShared' | 'accountId'>,
onFailure: 'drop' | 'keep',
): Promise<Record<string, ContactMedia> | undefined> {
const { media } = contact;
if (!media || !hasBlobMedia(media)) return media ?? undefined;
const accountId = mediaAccountId(contact);
return withInlineBlobs(media, (entry) => downloadAsDataUri(entry, accountId), onFailure);
}
/**
* The cards with their blob media turned into data: URIs, for a vCard export,
* which carries the bytes themselves.
*
* The blobs of all cards of one account come through a few batched Blob/get
* requests (getBlobsAsBase64): one download each would hit Stalwart's limit of
* 1000 requests a minute on a large address book, and a 429 stops every other
* request for a while. What the batch can't deliver (no blob capability, a
* refused request) is downloaded one by one; a blob that can't be downloaded
* at all is left out of the export. Never rejects.
*/
export async function inlineContactMediaForExport(contacts: ContactCard[]): Promise<ContactCard[]> {
const byAccount = new Map<string, Map<string, BlobMedia>>();
for (const contact of contacts) {
if (!hasBlobMedia(contact.media)) continue;
const accountId = mediaAccountId(contact);
let entries = byAccount.get(accountId);
if (!entries) {
entries = new Map();
byAccount.set(accountId, entries);
}
for (const entry of Object.values(contact.media ?? {})) {
if (isBlobOnly(entry)) entries.set(entry.blobId, entry);
}
}
const uris = new Map<string, string>();
for (const [accountId, entries] of byAccount) {
let oneByOne = [...entries.values()];
const batch = await getBlobsAsBase64([...entries.keys()], accountId).catch((err: unknown) => {
console.warn('[contacts] batched download of contact media failed', err);
return null;
});
if (batch) {
for (const [blobId, base64] of batch.data) {
const entry = entries.get(blobId);
if (entry) uris.set(`${accountId}|${blobId}`, `data:${entry.mediaType || 'application/octet-stream'};base64,${base64}`);
}
const failed = new Set(batch.failed);
oneByOne = oneByOne.filter((entry) => failed.has(entry.blobId));
}
for (const entry of oneByOne) {
const uri = await downloadAsDataUri(entry, accountId);
if (uri) uris.set(`${accountId}|${entry.blobId}`, uri);
}
}
const out: ContactCard[] = [];
for (const contact of contacts) {
if (!contact.media || !hasBlobMedia(contact.media)) {
out.push(contact);
continue;
}
const accountId = mediaAccountId(contact);
const media = await withInlineBlobs(
contact.media,
async (entry) => uris.get(`${accountId}|${entry.blobId}`) ?? null,
'drop',
);
out.push({ ...contact, media });
}
return out;
}
/** Forgets the accounts that refused blob media (tests). */
export function resetContactBlobMediaRefusals(): void {
blobMediaRefused.clear();
}