diff --git a/README.md b/README.md index 5d17f59..ad7b3ca 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a - Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel - Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved - Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge -- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default, folder management (create/rename/hide/share/empty), quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts +- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts **Calendar** (JMAP Calendars / JSCalendar) - Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 1800719..ca98d99 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -95,6 +95,8 @@ for (let i = 0; i < 45; i++) { } addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); +addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true }); +addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true }); // Invitation email { const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:ada@example.org\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index 3ef7507..2b8bf78 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -126,7 +126,10 @@ export interface MailState { archive(ids: Id[]): Promise; spam(ids: Id[], isSpam: boolean): Promise; emptyMailbox(mailboxId: Id): Promise; - markMailboxRead(mailboxId: Id): Promise; + /** Mark every unread message in a mailbox read; optionally its subfolders too. */ + markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise; + /** The mailbox plus all of its descendants. */ + descendantMailboxIds(mailboxId: Id): Id[]; createMailbox(name: string, parentId: Id | null): Promise; updateMailbox(id: Id, patch: Partial): Promise; @@ -568,16 +571,51 @@ export const useMail = create((set, get) => ({ } }, - async markMailboxRead(mailboxId) { + descendantMailboxIds(mailboxId) { + const all = Object.values(get().mailboxes); + const out: Id[] = [mailboxId]; + const walk = (parent: Id) => { + for (const m of all) { + if ((m.parentId ?? null) === parent) { + out.push(m.id); + walk(m.id); + } + } + }; + walk(mailboxId); + return out; + }, + + async markMailboxRead(mailboxId, includeChildren = false) { const accountId = get().accountId; if (!accountId) return; - try { + const boxes = includeChildren ? get().descendantMailboxIds(mailboxId) : [mailboxId]; + const unreadIn = async (filter: EmailFilter): Promise => { const res = await client.chain([ - ["Email/query", { accountId, filter: { inMailbox: mailboxId, notKeyword: "$seen" }, limit: 5000 }, "q"], + ["Email/query", { accountId, filter, limit: 5000 }, "q"], ["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: ["id"] }, "g"], ]); - const ids = ((res.get("g")?.[0] as unknown as GetResponse).list ?? []).map((e) => e.id); - if (ids.length) await get().markRead(ids, true); + return ((res.get("g")?.[0] as unknown as GetResponse).list ?? []).map((e) => e.id); + }; + try { + let ids: Id[]; + if (boxes.length === 1) { + ids = await unreadIn({ inMailbox: boxes[0]!, notKeyword: "$seen" }); + } else { + try { + ids = await unreadIn({ operator: "AND", conditions: [{ notKeyword: "$seen" }, { operator: "OR", conditions: boxes.map((id) => ({ inMailbox: id })) }] }); + } catch { + // Server without filter-operator support: one query per folder. + const per = await Promise.all(boxes.map((id) => unreadIn({ inMailbox: id, notKeyword: "$seen" }).catch(() => [] as Id[]))); + ids = [...new Set(per.flat())]; + } + } + if (!ids.length) { + toast.show("Nothing unread here"); + return; + } + await get().markRead(ids, true); + toast.success(`Marked ${ids.length} message${ids.length === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`); void get().loadMailboxes(); } catch (err) { toast.error(`Could not mark as read: ${(err as Error).message}`); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index c7487c9..072e20e 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -330,9 +330,19 @@ img { max-width: 100%; } .nav-item.active .nav-count { color: inherit; } .nav-item .nav-more { opacity: 0; width: 24px; height: 24px; margin-right: -6px; } .nav-item:hover .nav-more, .nav-item:focus-within .nav-more { opacity: 1; } -.nav-item .nav-twisty { width: 18px; height: 18px; margin-left: -8px; margin-right: -6px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-faint); border-radius: 4px; } +.nav-item .nav-twisty { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-faint); border-radius: 4px; } .nav-item .nav-twisty:hover { background: var(--bg-active); } -.nav-item.depth-1 { padding-left: 28px; } .nav-item.depth-2 { padding-left: 44px; } .nav-item.depth-3 { padding-left: 60px; } .nav-item.depth-4 { padding-left: 76px; } +/* Folder rows keep the expand chevron in a gutter to the LEFT of the icon, so a + folder with subfolders and one without still line up on their icon. */ +.nav-item.folder-row { --folder-indent: 0px; position: relative; padding-left: calc(30px + var(--folder-indent)); } +.nav-item.folder-row.depth-1 { --folder-indent: 16px; } +.nav-item.folder-row.depth-2 { --folder-indent: 32px; } +.nav-item.folder-row.depth-3 { --folder-indent: 48px; } +.nav-item.folder-row.depth-4 { --folder-indent: 64px; } +.nav-item.folder-row .nav-twisty { position: absolute; left: calc(8px + var(--folder-indent)); top: 50%; transform: translateY(-50%); margin: 0; } +/* Labels sit in the same column: a 20px slot matching the folder icons. */ +.nav-item.folder-row .nav-label-color { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; } +.nav-item.folder-row .nav-label-color::before { content: ""; width: 11px; height: 11px; border-radius: 3px; background: var(--label-color, var(--accent)); } .collapsed .nav-item { justify-content: center; padding: 0; margin: 0 auto; width: 44px; border-radius: 999px; } .collapsed .nav-item .nav-label, .collapsed .nav-item .nav-count, .collapsed .nav-item .nav-more, .collapsed .nav-item .nav-twisty { display: none; } .collapsed .nav-item.depth-1, .collapsed .nav-item.depth-2, .collapsed .nav-item.depth-3 { display: none; } diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 29923ec..f80514f 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -110,15 +110,15 @@ export function MailboxTree() { {labels.map((l) => ( - - + + {l.name} ))} )} - + {menuTarget && void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />} {shareTarget && setShareTarget(null)} />} @@ -156,7 +156,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, return ( setDropping(false)} @@ -166,23 +166,24 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, onMenu(m, { currentTarget: e.currentTarget }); }} > - {hasChildren ? ( - { - e.preventDefault(); - e.stopPropagation(); - onToggle(); - }} - > - {open ? : } - - ) : ( - depth > 0 && - )} + { + e.preventDefault(); + e.stopPropagation(); + onToggle(); + } + : undefined + } + > + {hasChildren ? open ? : : null} + {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} @@ -204,6 +205,20 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) { const [, navigate] = useLocation(); + const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id)); + const subUnread = useMail((s) => { + const all = Object.values(s.mailboxes); + let n = 0; + const walk = (parent: Id) => { + for (const x of all) + if ((x.parentId ?? null) === parent) { + n += x.unreadEmails; + walk(x.id); + } + }; + walk(m.id); + return n; + }); const rename = async () => { const name = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (!name?.trim() || name.trim() === m.name) return; @@ -232,6 +247,15 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; return ( <> } label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} /> + {hasChildren && ( + } + label="Mark all as read, incl. subfolders" + kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined} + onClick={() => void useMail.getState().markMailboxRead(m.id, true)} + disabled={!m.unreadEmails && !subUnread} + /> + )} } label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} /> } label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} /> : } label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />