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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "一致するものがありません",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Совпадений нет",
|
||||
|
||||
@@ -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": "Збігів немає",
|
||||
|
||||
@@ -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": "没有匹配项",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, Record<string, unknown>>; update?: Record<string, Record<string, unknown>> }
|
||||
|
||||
function server(boxes: Array<Partial<Mailbox> & { 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, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Mailbox/set") {
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
sets.push({ create, update });
|
||||
const created: Record<string, { id: string }> = {};
|
||||
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<Mailbox> & { id: string; name: string };
|
||||
|
||||
const mailboxesFrom = (boxes: Array<Partial<Mailbox> & { id: string }>) =>
|
||||
Object.fromEntries(boxes.map((b) => [b.id, b])) as unknown as Record<string, Mailbox>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+66
-5
@@ -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<Id>;
|
||||
createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise<Id>;
|
||||
/** Give something the Archive role -- adopting a folder already named for it, or making one. */
|
||||
ensureArchiveFolder(): Promise<Id>;
|
||||
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
|
||||
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
|
||||
|
||||
@@ -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>): 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<MailState>((set, get) => ({
|
||||
accountId: null,
|
||||
mailboxes: {},
|
||||
@@ -610,7 +640,7 @@ export const useMail = create<MailState>((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<MailState>((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<MailState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
async createMailbox(name, parentId) {
|
||||
async createMailbox(name, parentId, role) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } });
|
||||
const n: Record<string, unknown> = { 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<SetResponse<Mailbox>>("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.
|
||||
|
||||
@@ -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