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:
2026-09-02 08:38:08 -07:00
parent 8e9259638d
commit 5e6e049eef
13 changed files with 350 additions and 23 deletions
+65 -3
View File
@@ -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>