Merge pull request #103 from LINUXexpert-org/remember-added-shares

Remember an added address book when the server will not
This commit is contained in:
LINUXexpert.org
2026-08-27 12:08:13 -07:00
committed by GitHub
8 changed files with 148 additions and 16 deletions
+1
View File
@@ -24,6 +24,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
+14 -1
View File
@@ -751,7 +751,20 @@ const handlers: Record<string, Handler> = {
"Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
"AddressBook/set": (a) => genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a),
"AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
included -- "You are not allowed to modify this address book", confirmed
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
that accepted it would have agreed that subscribing works, which is
exactly the belief that shipped. Calendars accept the same write; the
difference is the server's, not ours. */
if (a.accountId === SHARED_ACCOUNT && a.update) {
const notUpdated: Obj = {};
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
}
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
},
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"),
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
/**
* Whether a shared collection counts as added.
*
* JMAP keeps this on the collection, in `isSubscribed`, and that is the better
* place: a preference the server holds is one every client sees. But
* subscribing writes to the *owner's* account, and Stalwart 0.16.19 refuses
* that for an address book shared read-only — "You are not allowed to modify
* this address book" — while accepting the identical write on a shared
* calendar. Confirmed against the live server on 2026-08-27, from a second
* account holding the share.
*
* So there are two records and either counts. The rule is the whole of the
* fix, which is why it is worth pinning down here rather than leaving it
* spelled out in three components that could drift apart.
*/
const key = (accountId: string, id: string) => `${accountId}:${id}`;
/** Added if the server remembered it, or the reader's settings did. */
function isAdded(collection: { accountId: string; id: string; isSubscribed?: boolean }, addedShares: string[]): boolean {
return Boolean(collection.isSubscribed) || new Set(addedShares).has(key(collection.accountId, collection.id));
}
const book = (over: Partial<{ accountId: string; id: string; isSubscribed: boolean }> = {}) =>
({ accountId: "acct", id: "ab1", ...over });
describe("whether a shared collection has been added", () => {
it("is added when the server took the subscription", () => {
expect(isAdded(book({ isSubscribed: true }), [])).toBe(true);
});
it("is added when only the settings remember it", () => {
// The address book case: the server refused the write.
expect(isAdded(book(), ["acct:ab1"])).toBe(true);
});
it("is not added when neither says so", () => {
expect(isAdded(book(), [])).toBe(false);
expect(isAdded(book(), ["other:ab1", "acct:ab2"])).toBe(false);
});
});
describe("keys are account-qualified", () => {
it("does not confuse the same id in another account", () => {
// Two accounts each having a book "ab1" is ordinary, not unlucky.
expect(isAdded(book({ accountId: "theirs" }), ["mine:ab1"])).toBe(false);
});
it("distinguishes two collections in one account", () => {
expect(isAdded(book({ id: "ab2" }), ["acct:ab1"])).toBe(false);
});
});
+23 -6
View File
@@ -2,8 +2,7 @@ import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings";
import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
export interface EventInstance {
@@ -150,13 +149,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
// See the note in the contacts store: subscribing writes to another
// account, so a refusal is an ordinary answer and arrives in `notUpdated`
// rather than as a thrown error.
/*
* Server first, settings when it refuses -- the same arrangement the
* contacts store explains. Stalwart takes this write on a shared calendar
* where it will not on a shared address book, but the difference is the
* server's to change and not worth relying on from here.
*/
let stored = false;
try {
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } });
const err = res.notUpdated?.[calendarId];
if (err) throw new Error(setErrorMessage(err));
} catch (err) {
toast.error(`Could not ${subscribed ? "add" : "remove"} that calendar: ${(err as Error).message}`);
return;
stored = true;
} catch {
stored = false;
}
if (!stored) {
const added = new Set(settings().addedShares);
if (subscribed) added.add(sharedKey(accountId, calendarId));
else added.delete(sharedKey(accountId, calendarId));
useSettings.getState().update({ addedShares: [...added] });
}
set((s) => ({
sharedCalendars: s.sharedCalendars.map((c) =>
@@ -281,8 +293,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
an account linked for its files offered its calendar too. `isSubscribed`
is the only thing separating "shared with me" from "reachable", so
nothing unsubscribed is drawn. */
const added = new Set(settings().addedShares);
const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) if (c.accountId === accountId && c.calendar.isSubscribed) theirs[c.calendar.id] = c.calendar;
for (const c of sharedCalendars) {
if (c.accountId !== accountId) continue;
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
theirs[c.calendar.id] = c.calendar;
}
if (calId && !theirs[calId]) continue;
const inst = toInstance(e, theirs);
if (!inst) continue;
+25 -5
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { toast } from "@/ui/toast";
import { useSettings } from "./settings";
import { useSession } from "./session";
import { useMail } from "./mail";
@@ -144,7 +144,8 @@ export const useContacts = create<ContactsState>((set, get) => ({
* put a stranger's contacts in the To field, which is the one place
* this must not guess.
*/
const wanted = new Set(res.list.filter((b) => b.isSubscribed).map((b) => b.id));
const added = new Set(useSettings.getState().settings.addedShares);
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
if (!wanted.size) continue;
// One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it.
@@ -175,13 +176,32 @@ export const useContacts = create<ContactsState>((set, get) => ({
* failure, not as a thrown error. Ignoring it made a refused subscribe look
* exactly like a button that does nothing.
*/
/*
* Ask the server to remember it, and remember it here when it will not.
*
* Subscribing writes to the owner's account, and Stalwart 0.16.19 refuses
* that for a book shared read-only -- "You are not allowed to modify this
* address book" -- while accepting the same write on a shared calendar. The
* server's own flag is still preferred when it takes it, because then every
* client agrees; a refusal is an ordinary answer here rather than a
* failure, and the preference goes in the reader's own synced settings.
*/
const key = sharedKey(accountId, bookId);
let stored = false;
try {
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
const err = res.notUpdated?.[bookId];
if (err) throw new Error(setErrorMessage(err));
} catch (err) {
toast.error(`Could not ${subscribed ? "add" : "remove"} that address book: ${(err as Error).message}`);
return;
stored = true;
} catch {
stored = false;
}
if (!stored) {
const { settings, update } = useSettings.getState();
const added = new Set(settings.addedShares);
if (subscribed) added.add(key);
else added.delete(key);
update({ addedShares: [...added] });
}
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
set({ selection: { accountId: null, bookId: "all" } });
+16
View File
@@ -33,6 +33,21 @@ export interface Settings {
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
/**
* Shared calendars and address books the reader has added, as
* `accountId:collectionId`.
*
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
* still tried first -- a preference the server holds is one every client
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
* refuses that for an address book shared read-only: "You are not allowed to
* modify this address book." It accepts the same write on a shared calendar,
* which is the inconsistency this list exists to paper over.
*
* So where the server will not remember, ihasmail does, in the settings that
* already follow the reader between devices.
*/
addedShares: string[];
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
@@ -128,6 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
addedShares: [],
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
+7 -2
View File
@@ -25,8 +25,13 @@ export function CalendarSidebar() {
const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu();
const sharedSubscribed = cal.sharedCalendars.filter((c) => c.calendar.isSubscribed);
const sharedAvailable = cal.sharedCalendars.filter((c) => !c.calendar.isSubscribed);
/* Added if the server says so or the reader's settings do; Stalwart will not
always take the flag, so the settings carry it where it refuses. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
const isAdded = (c: { accountId: string; calendar: { id: string; isSubscribed?: boolean } }) =>
Boolean(c.calendar.isSubscribed) || addedShares.has(`${c.accountId}:${c.calendar.id}`);
const sharedSubscribed = cal.sharedCalendars.filter(isAdded);
const sharedAvailable = cal.sharedCalendars.filter((c) => !isAdded(c));
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null);
+8 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings";
import type { AddressBook } from "@/jmap/types";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
@@ -44,6 +45,7 @@ export function ContactsSidebar() {
const onImport = (file: File) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: file }));
const onExport = () => window.dispatchEvent(new CustomEvent("ihm:contacts-export"));
const contacts = useContacts();
const settings = useSettings((s) => s.settings);
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
const [share, setShare] = useState<AddressBook | null>(null);
const [refreshing, setRefreshing] = useState(false);
@@ -58,8 +60,12 @@ export function ContactsSidebar() {
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const sel = contacts.selection;
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
const available = contacts.sharedBooks.filter((b) => !b.book.isSubscribed);
/* Added if the server says so or the reader's settings do -- Stalwart will
not take the flag on a book shared read-only, so the settings carry it. */
const added = new Set(settings.addedShares);
const isAdded = (accountId: string, bookId: string) => added.has(`${accountId}:${bookId}`);
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || isAdded(b.accountId, b.book.id));
const available = contacts.sharedBooks.filter((b) => !(b.book.isSubscribed || isAdded(b.accountId, b.book.id)));
return (
<>