Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs the Archive role on the server, which was true, and gave you nothing to do about it here. Roles were shown in Folders settings and never settable. Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an ordinary user through the proxy, with no admin API: setting role "archive" on a folder that had none returned updated and the folder began working as the Archive immediately. Stalwart parses the role names in SpecialUse::parse, "archive" among them, refuses a second holder of a role, and refuses to move the role of Inbox, Junk or Trash. So the toast now carries the fix. "No Archive folder is set yet." with a Create one that makes the folder and then completes the archiving that could not happen -- rather than leaving someone to select the same messages again. A folder already named Archive and carrying no role is adopted rather than duplicated. That is the state #217 was reported from, and a second Archive beside the first would be its own confusion. One named Archive that is really the Sent folder is left alone: taking its role to fix archiving would break sending. Folders settings gains a Role column. Archive, Drafts and Sent are offered, being the roles this client's behaviour depends on and the server will move; Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20 refuses. A role another folder holds is left out of the list rather than offered and refused, so freeing it is a deliberate two steps. The folder is created with the server's own name, never the localised one, for the reason renaming already writes back the server's: a German session must not create "Archiv" that an English one cannot find. Closes #217 properly.
This commit is contained in:
@@ -5,10 +5,30 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
|
||||
/*
|
||||
* Roles a folder can be given here.
|
||||
*
|
||||
* These are the three that ihasmail's own behaviour depends on and that
|
||||
* Stalwart will let move. Inbox, Junk and Trash are absent on purpose: 0.16.20
|
||||
* refuses them outright -- "You are not allowed to change the role of Inbox,
|
||||
* Junk or Trash folders" -- so offering them would only produce an error.
|
||||
*
|
||||
* `label` rather than a bare string so the catalogue sees them: they are
|
||||
* translated where they render.
|
||||
*/
|
||||
const SETTABLE_ROLES: { value: Exclude<MailboxRole, null>; label: string }[] = [
|
||||
{ value: "archive", label: "Archive" },
|
||||
{ value: "drafts", label: "Drafts" },
|
||||
{ value: "sent", label: "Sent" },
|
||||
];
|
||||
|
||||
/** Roles the server keeps to itself, shown but not offered. */
|
||||
const FIXED_ROLES = new Set<string>(["inbox", "junk", "trash"]);
|
||||
|
||||
export function FoldersSettings() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
@@ -16,6 +36,30 @@ export function FoldersSettings() {
|
||||
const list = useMemo(() => Object.values(mailboxes).map((m) => ({ m, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
|
||||
const quotas = useMail((s) => s.quotas);
|
||||
const q = quotas.find((x) => x.resourceType === "octets");
|
||||
/*
|
||||
* A role belongs to exactly one folder -- Stalwart answers "A mailbox with
|
||||
* role 'archive' already exists" -- so a role another folder holds is left
|
||||
* out of the list rather than offered and refused. Clearing it there frees it
|
||||
* here, which is two steps and no surprises.
|
||||
*/
|
||||
const taken = useMemo(() => {
|
||||
const by = new Map<string, string>();
|
||||
for (const m of Object.values(mailboxes)) if (m.role) by.set(m.role, m.id);
|
||||
return by;
|
||||
}, [mailboxes]);
|
||||
|
||||
const setRole = async (m: Mailbox, role: MailboxRole) => {
|
||||
try {
|
||||
await useMail.getState().updateMailbox(m.id, { role });
|
||||
// Deliberately not naming the role: the value is the protocol's word
|
||||
// ("archive"), and dropping an untranslated English token into nine
|
||||
// languages reads worse than saying nothing about it. The select already
|
||||
// shows what it now is.
|
||||
toast.success(t("Folder role updated"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name (use / for subfolders, e.g. Work/Invoices)") });
|
||||
@@ -39,11 +83,29 @@ export function FoldersSettings() {
|
||||
<p className="lead">{`${t("Create, rename and hide folders.")} ${q && q.hardLimit ? t("Storage: {used} of {total} used.", { used: formatSize(q.used), total: formatSize(q.hardLimit) }) : ""}`}</p>
|
||||
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> {t("New folder")}</button>
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>{t("Folder")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
|
||||
<thead><tr><th>{t("Folder")}</th><th>{t("Role")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{list.map(({ m, path }) => (
|
||||
<tr key={m.id}>
|
||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{mailboxDisplayPath(m, mailboxes)}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{mailboxDisplayPath(m, mailboxes)}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}</div></td>
|
||||
<td>
|
||||
<select
|
||||
className="input"
|
||||
value={m.role ?? ""}
|
||||
disabled={FIXED_ROLES.has(m.role ?? "")}
|
||||
title={FIXED_ROLES.has(m.role ?? "") ? t("The server does not allow this role to be changed.") : undefined}
|
||||
onChange={(e) => void setRole(m, (e.target.value || null) as MailboxRole)}
|
||||
>
|
||||
<option value="">{t("None")}</option>
|
||||
{SETTABLE_ROLES.filter((o) => o.value === m.role || !taken.has(o.value)).map((o) => (
|
||||
<option key={o.value} value={o.value}>{t(o.label)}</option>
|
||||
))}
|
||||
{/* A role this build does not offer -- inbox, junk, trash, or
|
||||
anything a future server invents -- still has to show as
|
||||
what it is rather than as "None". */}
|
||||
{m.role && !SETTABLE_ROLES.some((o) => o.value === m.role) && <option value={m.role}>{m.role}</option>}
|
||||
</select>
|
||||
</td>
|
||||
<td>{m.totalEmails.toLocaleString()}</td>
|
||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||
<td>
|
||||
|
||||
Reference in New Issue
Block a user