Merge pull request #109 from LINUXexpert-org/ask-for-sharewith
Ask for shareWith, or the server does not send it
This commit is contained in:
@@ -25,6 +25,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars and address books fetched everything and got less. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -379,6 +379,19 @@ function enforceLimits(name: string, args: Obj): void {
|
|||||||
|
|
||||||
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
||||||
|
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
||||||
|
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
||||||
|
* shared. The mock handing it over unasked meant a client that never asked
|
||||||
|
* still saw every share, and the one place that did not -- the real server --
|
||||||
|
* showed nothing shared at all.
|
||||||
|
*/
|
||||||
|
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||||
|
if (a.properties) return res;
|
||||||
|
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||||
|
}
|
||||||
|
|
||||||
function genericGet(list: Obj[]) {
|
function genericGet(list: Obj[]) {
|
||||||
return (a: Obj) => {
|
return (a: Obj) => {
|
||||||
const ids = a.ids as string[] | null | undefined;
|
const ids = a.ids as string[] | null | undefined;
|
||||||
@@ -759,7 +772,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"SieveScript/get": genericGet(sieveScripts),
|
"SieveScript/get": genericGet(sieveScripts),
|
||||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
||||||
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
||||||
@@ -778,7 +791,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||||
"Principal/get": genericGet(principals),
|
"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 }] }),
|
"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/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||||
"AddressBook/set": (a) => {
|
"AddressBook/set": (a) => {
|
||||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||||
included -- "You are not allowed to modify this address book", confirmed
|
included -- "You are not allowed to modify this address book", confirmed
|
||||||
|
|||||||
@@ -15,6 +15,37 @@ export interface EventInstance {
|
|||||||
calendar: Calendar | undefined;
|
calendar: Calendar | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Asked for by name, because `shareWith` is not among the properties Stalwart
|
||||||
|
* returns by default.
|
||||||
|
*
|
||||||
|
* A `Calendar/get` with no `properties` comes back without it -- not null, not
|
||||||
|
* empty, absent -- confirmed against 0.16.19 on 2026-08-27 with a calendar that
|
||||||
|
* was genuinely shared: omit the list and there is no `shareWith`; name it and
|
||||||
|
* the sharee is right there. So the client believed nothing was ever shared.
|
||||||
|
* The badge never appeared, "Stop sharing" never appeared, and the share dialog
|
||||||
|
* opened on "not shared with anyone yet" over a live share.
|
||||||
|
*
|
||||||
|
* Files had this right already, for the same reason and after the same
|
||||||
|
* surprise; calendars and address books did not.
|
||||||
|
*/
|
||||||
|
export const CALENDAR_PROPS = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"color",
|
||||||
|
"sortOrder",
|
||||||
|
"isSubscribed",
|
||||||
|
"isVisible",
|
||||||
|
"isDefault",
|
||||||
|
"includeInAvailability",
|
||||||
|
"defaultAlertsWithTime",
|
||||||
|
"defaultAlertsWithoutTime",
|
||||||
|
"timeZone",
|
||||||
|
"shareWith",
|
||||||
|
"myRights",
|
||||||
|
];
|
||||||
|
|
||||||
/** A calendar somebody else shared, and the account it lives in. */
|
/** A calendar somebody else shared, and the account it lives in. */
|
||||||
export interface SharedCalendar {
|
export interface SharedCalendar {
|
||||||
accountId: Id;
|
accountId: Id;
|
||||||
@@ -131,7 +162,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const found: SharedCalendar[] = [];
|
const found: SharedCalendar[] = [];
|
||||||
for (const [accountId, account] of accounts) {
|
for (const [accountId, account] of accounts) {
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
||||||
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -216,7 +247,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
||||||
const calendars: Record<Id, Calendar> = {};
|
const calendars: Record<Id, Calendar> = {};
|
||||||
for (const c of res.list) calendars[c.id] = c;
|
for (const c of res.list) calendars[c.id] = c;
|
||||||
set({ calendars, error: null });
|
set({ calendars, error: null });
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export interface Suggestion {
|
|||||||
photo?: string | null;
|
photo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Asked for by name: `shareWith` is not returned by default.
|
||||||
|
*
|
||||||
|
* An `AddressBook/get` with no `properties` omits it entirely -- confirmed
|
||||||
|
* against 0.16.19 on 2026-08-27 on a book that really was shared. See the note
|
||||||
|
* on CALENDAR_PROPS; both had the same hole and Files did not.
|
||||||
|
*/
|
||||||
|
export const ADDRESS_BOOK_PROPS = ["id", "name", "description", "sortOrder", "isDefault", "isSubscribed", "shareWith", "myRights"];
|
||||||
|
|
||||||
/** A book somebody else shared, and the account it lives in. */
|
/** A book somebody else shared, and the account it lives in. */
|
||||||
export interface SharedBook {
|
export interface SharedBook {
|
||||||
accountId: Id;
|
accountId: Id;
|
||||||
@@ -132,7 +141,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
const cards: Record<string, ContactCard> = {};
|
const cards: Record<string, ContactCard> = {};
|
||||||
for (const [accountId, account] of accounts) {
|
for (const [accountId, account] of accounts) {
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
||||||
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
||||||
/*
|
/*
|
||||||
* Cards come only from books the reader has added.
|
* Cards come only from books the reader has added.
|
||||||
@@ -223,7 +232,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
||||||
const books: Record<Id, AddressBook> = {};
|
const books: Record<Id, AddressBook> = {};
|
||||||
for (const b of res.list) books[b.id] = b;
|
for (const b of res.list) books[b.id] = b;
|
||||||
set({ books, error: null });
|
set({ books, error: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user