Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith` map, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with -- confirmed on the live 0.16.19 with a folder shared read-only to another account on the same server, which never saw it. Stalwart's sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing anywhere reports a failure, so a client that trusts what it reads back shows the share as live for ever, which is what happened. The entry point is withdrawn. Address book sharing goes with it on a report that it behaved the same way -- not reproduced, and contradicted by Stalwart's own docs, so that one is expected back; it is out because offering a share nobody can verify was worse than the gap. Files and calendars are untouched. Removing a share was impossible, for a reason worth writing down. The dialog rendered the list of who a thing was shared with *inside* the branch that runs when the directory has principals to offer. A server with `allowDirectoryQueries` off returns none -- that is the default, and it is how these shares came to be made in the first place -- so the dialog showed one line of hint and nothing else. The share was there, and there was no way to see it, let alone remove it. The list is now rendered whatever the directory says; only the control for adding somebody new depends on having somebody to add. So the withdrawn entry points do not strand what they created: a folder or book already shared still offers "Stop sharing", which is the one thing you want when the share is invisible everywhere else. The API was never the problem, which is worth recording since it was the first guess: `shareWith: null` is accepted and clears the map, tested against the live server on the stuck folder, which is now unshared.
This commit is contained in:
@@ -105,7 +105,14 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name }); if (n?.trim()) void contacts.updateBook(menuBook.id, { name: n.trim() }).catch((err) => toast.error((err as Error).message)); }} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuBook)} />
|
||||
{/* Withdrawn alongside mail folder sharing, on a report that it
|
||||
behaved the same way -- which was never reproduced, and which
|
||||
Stalwart's own docs contradict, since address books are listed
|
||||
as shareable. Expected back once two accounts have confirmed a
|
||||
book actually arrives. Clearing one still works. */}
|
||||
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
|
||||
<MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={() => setShare(menuBook)} />
|
||||
)}
|
||||
<MenuItem icon={<Star size={16} />} label={menuBook.isDefault ? "Default book" : "Make default"} disabled={menuBook.isDefault} onClick={() => void contacts.updateBook(menuBook.id, { isDefault: true } as Partial<AddressBook>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuBook.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "All contacts in it will be deleted.", confirmLabel: "Delete", danger: true })) void contacts.destroyBook(menuBook.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
|
||||
@@ -292,6 +292,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
}
|
||||
|
||||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
|
||||
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||||
const [, navigate] = useLocation();
|
||||
const colors = useSettings((s) => s.settings.folderColors);
|
||||
const update = useSettings((s) => s.update);
|
||||
@@ -354,7 +355,13 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
|
||||
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||
stores the share, and it never reaches the other account -- its own
|
||||
docs list calendars, address books and files as shareable and not mail
|
||||
folders. Offering it produced shares that looked real and did nothing.
|
||||
One that already exists can still be cleared here, which is the only
|
||||
reason this entry survives at all. */}
|
||||
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FoldersSettings() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Folders</h1>
|
||||
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
|
||||
@@ -48,7 +48,7 @@ export function FoldersSettings() {
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
|
||||
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title="Stop sharing" onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
||||
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -104,9 +104,17 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
|
||||
{!principals.length ? (
|
||||
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
|
||||
) : (
|
||||
{/* The list of who it is shared with is rendered whether or not anybody
|
||||
can be *added*. It used to sit inside the branch below, so a server
|
||||
with directory queries switched off -- which is the default, and which
|
||||
returns no principals -- showed nothing but the hint, and an existing
|
||||
share could not be seen, let alone removed. */}
|
||||
{!principals.length && (
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.
|
||||
</p>
|
||||
)}
|
||||
{principals.length > 0 && (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
@@ -118,7 +126,9 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
|
||||
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
|
||||
</div>
|
||||
{Object.entries(rights).map(([pid, r]) => {
|
||||
</>
|
||||
)}
|
||||
{Object.entries(rights).map(([pid, r]) => {
|
||||
const p = principals.find((x) => x.id === pid);
|
||||
return (
|
||||
<div key={pid} className="card">
|
||||
@@ -136,10 +146,8 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
|
||||
</>
|
||||
)}
|
||||
})}
|
||||
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user