From 5e6e049eef9d5fba803514219401a60341c17062 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 08:38:08 -0700 Subject: [PATCH] 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. --- web/src/locales/de.ts | 9 +- web/src/locales/es.ts | 9 +- web/src/locales/fr.ts | 9 +- web/src/locales/ja.ts | 9 +- web/src/locales/nl.ts | 9 +- web/src/locales/pt-BR.ts | 9 +- web/src/locales/ru.ts | 9 +- web/src/locales/uk.ts | 9 +- web/src/locales/zh-Hans.ts | 9 +- .../store/__tests__/archive-by-date.test.ts | 12 +- web/src/store/__tests__/archive-role.test.ts | 141 ++++++++++++++++++ web/src/store/mail.ts | 71 ++++++++- web/src/views/settings/FoldersSettings.tsx | 68 ++++++++- 13 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 web/src/store/__tests__/archive-role.test.ts diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 30efcc6..3a8c017 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -986,7 +986,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Neuer ganztägiger Termin am {date}", "New event at {time}": "Neuer Termin um {time}", "New identity": "Neue Absenderidentität", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Kein Archivordner festgelegt. Ein Ordner braucht auf dem Server die Rolle „Archive“; ihn so zu benennen genügt nicht.", + "No Archive folder is set yet.": "Es ist noch kein Archivordner festgelegt.", + "Create one": "Anlegen", + "Could not set up an Archive folder: {error}": "Archivordner konnte nicht eingerichtet werden: {error}", + "Role": "Rolle", + "Drafts": "Entwürfe", + "Sent": "Gesendet", + "The server does not allow this role to be changed.": "Der Server lässt eine Änderung dieser Rolle nicht zu.", + "Folder role updated": "Ordnerrolle aktualisiert", "No contacts yet": "Noch keine Kontakte", "No longer shared": "Nicht mehr freigegeben", "No matches": "Keine Treffer", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 82fbb2e..9fe81f6 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -959,7 +959,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Nuevo evento de todo el día el {date}", "New event at {time}": "Nuevo evento a las {time}", "New identity": "Nueva identidad", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "No hay carpeta de archivo definida. Una carpeta necesita el rol «Archive» en el servidor; no basta con llamarla así.", + "No Archive folder is set yet.": "Aún no hay ninguna carpeta de archivo definida.", + "Create one": "Crear una", + "Could not set up an Archive folder: {error}": "No se pudo configurar una carpeta de archivo: {error}", + "Role": "Rol", + "Drafts": "Borradores", + "Sent": "Enviados", + "The server does not allow this role to be changed.": "El servidor no permite cambiar este rol.", + "Folder role updated": "Rol de la carpeta actualizado", "No contacts yet": "Aún no hay contactos", "No longer shared": "Ya no está compartido", "No matches": "Sin coincidencias", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index f1211dc..39cc784 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -964,7 +964,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Nouvel événement sur la journée du {date}", "New event at {time}": "Nouvel événement à {time}", "New identity": "Nouvelle identité", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Aucun dossier d’archive défini. Un dossier doit avoir le rôle « Archive » sur le serveur ; le nommer ainsi ne suffit pas.", + "No Archive folder is set yet.": "Aucun dossier d’archive n’est encore défini.", + "Create one": "En créer un", + "Could not set up an Archive folder: {error}": "Impossible de configurer un dossier d’archive : {error}", + "Role": "Rôle", + "Drafts": "Brouillons", + "Sent": "Envoyés", + "The server does not allow this role to be changed.": "Le serveur n’autorise pas la modification de ce rôle.", + "Folder role updated": "Rôle du dossier mis à jour", "No contacts yet": "Pas encore de contacts", "No longer shared": "N’est plus partagé", "No matches": "Aucune correspondance", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index b3372a8..ae10631 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -967,7 +967,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "{date} に終日の予定を作成", "New event at {time}": "{time} に予定を作成", "New identity": "新しい差出人", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "アーカイブフォルダーが設定されていません。サーバーでフォルダーに「Archive」ロールが必要です。名前をそうするだけでは不十分です。", + "No Archive folder is set yet.": "アーカイブフォルダーがまだ設定されていません。", + "Create one": "作成する", + "Could not set up an Archive folder: {error}": "アーカイブフォルダーを設定できませんでした: {error}", + "Role": "ロール", + "Drafts": "下書き", + "Sent": "送信済み", + "The server does not allow this role to be changed.": "サーバーはこのロールの変更を許可していません。", + "Folder role updated": "フォルダーのロールを更新しました", "No contacts yet": "連絡先がまだありません", "No longer shared": "共有を解除しました", "No matches": "一致するものがありません", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index a7bbf9b..19ab17d 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -955,7 +955,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}", "New event at {time}": "Nieuwe afspraak om {time}", "New identity": "Nieuwe afzender", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Geen archiefmap ingesteld. Een map heeft op de server de rol ‘Archive’ nodig; die naam geven is niet genoeg.", + "No Archive folder is set yet.": "Er is nog geen archiefmap ingesteld.", + "Create one": "Er een maken", + "Could not set up an Archive folder: {error}": "Kon geen archiefmap instellen: {error}", + "Role": "Rol", + "Drafts": "Concepten", + "Sent": "Verzonden", + "The server does not allow this role to be changed.": "De server staat niet toe deze rol te wijzigen.", + "Folder role updated": "Maprol bijgewerkt", "No contacts yet": "Nog geen contacten", "No longer shared": "Niet langer gedeeld", "No matches": "Geen overeenkomsten", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index a8c4434..2790cf7 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -962,7 +962,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Novo evento de dia inteiro em {date}", "New event at {time}": "Novo evento às {time}", "New identity": "Nova identidade", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Nenhuma pasta de arquivamento definida. Uma pasta precisa da função “Archive” no servidor; nomeá-la assim não basta.", + "No Archive folder is set yet.": "Ainda não há uma pasta de arquivamento definida.", + "Create one": "Criar uma", + "Could not set up an Archive folder: {error}": "Não foi possível configurar uma pasta de arquivamento: {error}", + "Role": "Função", + "Drafts": "Rascunhos", + "Sent": "Enviados", + "The server does not allow this role to be changed.": "O servidor não permite alterar esta função.", + "Folder role updated": "Função da pasta atualizada", "No contacts yet": "Ainda não há contatos", "No longer shared": "Não está mais compartilhado", "No matches": "Nenhuma correspondência", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index d5e064c..70a623b 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -961,7 +961,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Новое событие на весь день {date}", "New event at {time}": "Новое событие в {time}", "New identity": "Новый профиль отправителя", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Папка архива не задана. На сервере папке нужна роль «Archive»; просто назвать её так недостаточно.", + "No Archive folder is set yet.": "Папка архива ещё не задана.", + "Create one": "Создать", + "Could not set up an Archive folder: {error}": "Не удалось настроить папку архива: {error}", + "Role": "Роль", + "Drafts": "Черновики", + "Sent": "Отправленные", + "The server does not allow this role to be changed.": "Сервер не позволяет изменить эту роль.", + "Folder role updated": "Роль папки обновлена", "No contacts yet": "Контактов пока нет", "No longer shared": "Общий доступ прекращён", "No matches": "Совпадений нет", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index fb43b18..4119488 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -955,7 +955,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "Нова подія на весь день {date}", "New event at {time}": "Нова подія о {time}", "New identity": "Новий профіль відправника", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "Теку архіву не задано. На сервері теці потрібна роль «Archive»; просто назвати її так недостатньо.", + "No Archive folder is set yet.": "Теку архіву ще не задано.", + "Create one": "Створити", + "Could not set up an Archive folder: {error}": "Не вдалося налаштувати теку архіву: {error}", + "Role": "Роль", + "Drafts": "Чернетки", + "Sent": "Надіслані", + "The server does not allow this role to be changed.": "Сервер не дозволяє змінити цю роль.", + "Folder role updated": "Роль теки оновлено", "No contacts yet": "Контактів ще немає", "No longer shared": "Спільний доступ припинено", "No matches": "Збігів немає", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 68d9143..a429853 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -966,7 +966,14 @@ export const catalog: Catalog = { "New all-day event on {date}": "在 {date} 新建全天日程", "New event at {time}": "在 {time} 新建日程", "New identity": "新建发件身份", - "No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.": "未设置归档文件夹。文件夹需要在服务器上具有「Archive」角色,仅命名为此并不够。", + "No Archive folder is set yet.": "尚未设置归档文件夹。", + "Create one": "创建一个", + "Could not set up an Archive folder: {error}": "无法设置归档文件夹:{error}", + "Role": "角色", + "Drafts": "草稿", + "Sent": "已发送", + "The server does not allow this role to be changed.": "服务器不允许更改此角色。", + "Folder role updated": "已更新文件夹角色", "No contacts yet": "还没有联系人", "No longer shared": "已停止共享", "No matches": "没有匹配项", diff --git a/web/src/store/__tests__/archive-by-date.test.ts b/web/src/store/__tests__/archive-by-date.test.ts index 37a2cf4..b472674 100644 --- a/web/src/store/__tests__/archive-by-date.test.ts +++ b/web/src/store/__tests__/archive-by-date.test.ts @@ -178,13 +178,13 @@ describe("archiveByDate", () => { expect(s.moves).toEqual([]); expect(messages()[0]).toContain("No Archive folder"); /* - * What it says, not just that it complains. The folder is found by its - * special-use role and by nothing else, so telling someone to create a - * folder *named* "Archive" sent them round a loop that could not end -- - * naming a folder does not give it the role, and ihasmail cannot assign - * one. Issue #217. + * And it offers to fix it. The folder is found by its special-use role and + * by nothing else, so telling someone to create a folder *named* "Archive" + * sent them round a loop that could not end. ihasmail can set the role + * itself, so the toast carries the action rather than the explanation. + * Issue #217. */ - expect(messages()[0]).toContain("role"); + expect(useToasts.getState().toasts[0]?.action?.label).toBeTruthy(); expect(messages()[0]).not.toMatch(/Create one named/); }); diff --git a/web/src/store/__tests__/archive-role.test.ts b/web/src/store/__tests__/archive-role.test.ts new file mode 100644 index 0000000..8954331 --- /dev/null +++ b/web/src/store/__tests__/archive-role.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useMail } from "@/store/mail"; +import { useToasts } from "@/ui/toast"; +import type { JmapSession, Mailbox } from "@/jmap/types"; + +/* + * Giving a folder the Archive role. + * + * The whole of #217 was one JMAP property ihasmail never sent. `Mailbox/set` + * takes `role` -- confirmed live against 0.16.20, as an ordinary user through + * the proxy -- so a missing Archive is something the client can fix rather than + * describe. These pin what it sends, and the one case that matters most: a + * folder already *named* Archive and carrying no role, which is the state the + * issue was reported from. + */ + +interface SetArgs { create?: Record>; update?: Record> } + +function server(boxes: Array & { id: string; name: string }>) { + const sets: SetArgs[] = []; + const live = new Map(boxes.map((b) => [b.id, { parentId: null, role: null, ...b }])); + let counter = 0; + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "Mailbox/set") { + const create = args.create as Record> | undefined; + const update = args.update as Record> | undefined; + sets.push({ create, update }); + const created: Record = {}; + for (const [cid, spec] of Object.entries(create ?? {})) { + const newId = `mb-new-${++counter}`; + live.set(newId, { id: newId, name: String(spec.name), parentId: null, role: (spec.role as Mailbox["role"]) ?? null }); + created[cid] = { id: newId }; + } + for (const [mid, patch] of Object.entries(update ?? {})) { + const cur = live.get(mid); + if (cur) live.set(mid, { ...cur, ...(patch as object) } as typeof cur); + } + return [name, { accountId: "a1", oldState: "1", newState: "2", created, updated: {}, notCreated: {}, notUpdated: {} }, id]; + } + if (name === "Mailbox/get") return [name, { accountId: "a1", state: "1", list: [...live.values()], notFound: [] }, id]; + if (name === "Email/set") return [name, { accountId: "a1", oldState: "1", newState: "2", updated: {}, notUpdated: {} }, id]; + return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id]; + }); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return { sets, live }; +} + +const box = (id: string, name: string, role: Mailbox["role"] = null) => ({ id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0 }) as Partial & { id: string; name: string }; + +const mailboxesFrom = (boxes: Array & { id: string }>) => + Object.fromEntries(boxes.map((b) => [b.id, b])) as unknown as Record; + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {} }, + accounts: {}, primaryAccounts: {}, state: "s1", + } as unknown as JmapSession; + useToasts.setState({ toasts: [] }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("giving a folder the Archive role", () => { + it("adopts a folder already named Archive that carries no role", async () => { + /* + * The reported state, exactly: the folder is there, the name is right, and + * archiving cannot see it because the role is what archiving looks for. + * Making a second Archive beside it would be its own confusion. + */ + const boxes = [box("in", "Inbox", "inbox"), box("ew", "Archive")]; + const { sets } = server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes) }); + await expect(useMail.getState().ensureArchiveFolder()).resolves.toBe("ew"); + expect(sets[0]!.update).toEqual({ ew: { role: "archive" } }); + expect(sets.some((s) => s.create)).toBe(false); + }); + + it("matches that folder whatever its case and spacing", async () => { + const boxes = [box("in", "Inbox", "inbox"), box("ew", " archive ")]; + server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes) }); + await expect(useMail.getState().ensureArchiveFolder()).resolves.toBe("ew"); + }); + + it("creates one, with the role, when there is nothing to adopt", async () => { + const boxes = [box("in", "Inbox", "inbox")]; + const { sets } = server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes) }); + const id = await useMail.getState().ensureArchiveFolder(); + expect(sets[0]!.create!.n).toMatchObject({ name: "Archive", role: "archive", parentId: null }); + expect(id).toBe("mb-new-1"); + }); + + it("leaves a folder that already holds another role alone", async () => { + // Named Archive, but it is the Sent folder. Taking its role away to make it + // the Archive would break sending to fix archiving. + const boxes = [box("in", "Inbox", "inbox"), box("s1", "Archive", "sent")]; + const { sets } = server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes) }); + await useMail.getState().ensureArchiveFolder(); + expect(sets[0]!.create!.n).toMatchObject({ name: "Archive", role: "archive" }); + }); + + it("does not send a role on an ordinary new folder", async () => { + const boxes = [box("in", "Inbox", "inbox")]; + const { sets } = server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes) }); + await useMail.getState().createMailbox("Invoices", null); + expect(sets[0]!.create!.n).not.toHaveProperty("role"); + }); +}); + +describe("archiving with no Archive folder", () => { + const boxes = [box("in", "Inbox", "inbox")]; + + it("offers to set one up rather than only saying it is missing", async () => { + server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes), emails: {} as never }); + await useMail.getState().archive(["e1"]); + const t = useToasts.getState().toasts[0]!; + expect(t.message).toContain("No Archive folder"); + expect(t.action?.label).toBeTruthy(); + }); + + it("makes the folder and finishes the archiving when that offer is taken", async () => { + const { sets, live } = server(boxes); + useMail.setState({ accountId: "a1", mailboxes: mailboxesFrom(boxes), emails: { e1: { id: "e1", mailboxIds: { in: true } } } as never }); + await useMail.getState().archive(["e1"]); + await useToasts.getState().toasts[0]!.action!.onClick(); + expect(sets[0]!.create!.n).toMatchObject({ name: "Archive", role: "archive" }); + expect([...live.values()].some((m) => m.role === "archive")).toBe(true); + }); +}); diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index 182df2e..0defeb4 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -178,7 +178,9 @@ export interface MailState { /** The mailbox plus all of its descendants. */ descendantMailboxIds(mailboxId: Id): Id[]; - createMailbox(name: string, parentId: Id | null): Promise; + createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise; + /** Give something the Archive role -- adopting a folder already named for it, or making one. */ + ensureArchiveFolder(): Promise; updateMailbox(id: Id, patch: Partial): Promise; destroyMailbox(id: Id, removeEmails?: boolean): Promise; @@ -213,6 +215,34 @@ function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }]; +/** + * Nothing carries the Archive role, so offer to fix it rather than explain it. + * + * The message this replaces described the problem accurately and left the + * reader with nothing to do inside ihasmail -- roles were only ever shown, not + * set. `Mailbox/set` takes `role`, so the offer is real: one click makes the + * folder and files the messages that were being archived when it was missing. + * + * `retry` is the archiving that could not happen, handed back so the click + * finishes the job rather than leaving someone to select the same messages + * again. + */ +function offerArchiveFolder(retry: () => Promise): void { + toast.error(t("No Archive folder is set yet."), { + action: { + label: t("Create one"), + onClick: async () => { + try { + await useMail.getState().ensureArchiveFolder(); + await retry(); + } catch (err) { + toast.error(t("Could not set up an Archive folder: {error}", { error: (err as Error).message })); + } + }, + }, + }); +} + export const useMail = create((set, get) => ({ accountId: null, mailboxes: {}, @@ -610,7 +640,7 @@ export const useMail = create((set, get) => ({ async archive(ids) { const archiveId = get().roleId("archive") ?? get().roleId("all"); if (!archiveId) { - toast.error(t("No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.")); + offerArchiveFolder(() => get().archive(ids)); return; } await get().move(ids, archiveId, { label: "Archive" }); @@ -621,7 +651,7 @@ export const useMail = create((set, get) => ({ const archiveId = get().roleId("archive") ?? get().roleId("all"); if (!accountId || !ids.length) return; if (!archiveId) { - toast.error(t("No Archive folder is set. A folder needs the Archive role on the server; naming it “Archive” is not enough.")); + offerArchiveFolder(() => get().archiveByDate(ids, granularity)); return; } const { emails } = get(); @@ -809,15 +839,46 @@ export const useMail = create((set, get) => ({ } }, - async createMailbox(name, parentId) { + async createMailbox(name, parentId, role) { const accountId = get().accountId!; - const res = await client.call>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } }); + const n: Record = { name, parentId, isSubscribed: true }; + // Only when asked. Sending `role: null` on every create would be harmless + // and would still say something the caller did not. + if (role) n.role = role; + const res = await client.call>("Mailbox/set", { accountId, create: { n } }); const err = res.notCreated?.n; if (err) throw new Error(setErrorMessage(err)); await get().loadMailboxes(); return res.created!.n!.id; }, + /* + * The Archive folder, made rather than described. + * + * `Mailbox/set` takes `role` -- confirmed live against 0.16.20 on 2026-09-02, + * as an ordinary user through the proxy, no admin API -- so a missing Archive + * is something ihasmail can fix instead of explaining a server-side concept + * and leaving. Stalwart parses the role names in `SpecialUse::parse`, of + * which "archive" is one, and enforces that a role is held by one folder. + * + * A folder already *named* Archive but carrying no role is adopted rather + * than duplicated. That is exactly the state #217 was reported from -- a + * folder with the right name and no role, which archiving could not see -- + * and creating a second Archive beside it would be its own confusion. + * + * The name is the server's, not a translated one, for the same reason + * renaming writes back the server's own: a folder's name is data, and a + * German session must not create "Archiv" that an English one cannot find. + */ + async ensureArchiveFolder() { + const existing = Object.values(get().mailboxes).find((m) => !m.role && m.name.trim().toLowerCase() === "archive"); + if (existing) { + await get().updateMailbox(existing.id, { role: "archive" }); + return existing.id; + } + return get().createMailbox("Archive", null, "archive"); + }, + async updateMailbox(id, patch) { const accountId = get().accountId!; // Paths as the filter rules currently spell them, before the move. diff --git a/web/src/views/settings/FoldersSettings.tsx b/web/src/views/settings/FoldersSettings.tsx index 66ff5bb..8f92867 100644 --- a/web/src/views/settings/FoldersSettings.tsx +++ b/web/src/views/settings/FoldersSettings.tsx @@ -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; 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(["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(); + 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() {

{`${t("Create, rename and hide folders.")} ${q && q.hardLimit ? t("Storage: {used} of {total} used.", { used: formatSize(q.used), total: formatSize(q.hardLimit) }) : ""}`}

- + {list.map(({ m, path }) => ( - + +
{t("Folder")}{t("Messages")}{t("Unread")}
{t("Folder")}{t("Role")}{t("Messages")}{t("Unread")}
{m.role === "inbox" ? : }{mailboxDisplayPath(m, mailboxes)}{!m.isSubscribed && {t("hidden")}}{m.role && m.role !== "subscribed" && ({m.role})}
{m.role === "inbox" ? : }{mailboxDisplayPath(m, mailboxes)}{!m.isSubscribed && {t("hidden")}}
+ + {m.totalEmails.toLocaleString()} {m.unreadEmails.toLocaleString()}