Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions specs/001-mvp-scope/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ capability.
|:--|:--|
| R-2.1 🟩 Done | The system shall display the signed-in account's folder hierarchy with role icons and per-folder unread counts. Role-based mailboxes (Inbox, Drafts, Sent, Archive, Junk, Trash) shall render first using their dedicated Thunderbird Desktop icons; all remaining mailboxes shall render below a "Folders" heading and shall use the Thunderbird Desktop generic folder icon in its goldenrod tone rather than a neutral grey. Per-folder unread counts shall display up to 99999 before truncating with a "+" suffix, and the spaces-toolbar unread badge shall show the Inbox unread count up to 9999 before truncating with a "+" suffix. |
| R-2.2 🟩 Done | The system shall render a virtualized message list whose scrollbar reflects the full folder size, with placeholder rows for positions not yet fetched. |
| R-2.3 🟩 Done | When the user opens a message, the system shall display its sanitized HTML body in a sandboxed iframe with no script execution, or its plain-text body when HTML is unavailable. |
| R-2.3 🟩 Done | When the user opens a message, the system shall display its sanitized HTML body in a sandboxed iframe with no script execution, or its plain-text body when HTML is unavailable. While the body is still loading the system shall show a loading placeholder, but once the body has loaded with neither HTML nor plain-text content the system shall render an empty body rather than leaving the loading placeholder in place. |
| R-2.4 🟩 Done | While the user reads a message, the system shall mark the message as read. |
| R-2.5 🟩 Done | The system shall display attachment metadata (name, type, size) on the open message. |
| R-2.6 🟩 Done | The system shall persist scroll position per folder so re-entering a folder restores the previous view. |
Expand All @@ -66,7 +66,7 @@ capability.
| R-2.10 🟧 Planned | The system shall use route-backed browser navigation for app spaces, folders, and opened messages so refresh, direct links, and the browser back and forward buttons preserve the expected mail context. Folder URLs shall use human-readable names where possible, and message URLs shall use stable server identifiers rather than local cache row ids. |
| R-2.11 🟩 Done | When an opened message references inline images by `cid:`, the system shall resolve them for display rather than leaving a broken reference: it shall download the referenced message part through the authenticated worker transport and rewrite the `cid:` reference to an inline `data:` URL inside the same sanitization pass (set via the DOM so the value cannot break out of the attribute). Resolution shall apply only to parts of the message being viewed and only to references the body actually uses. Both resolved inline images and author-embedded `data:` images shall be restricted to a raster image allowlist (PNG, JPEG, GIF, WebP, BMP, AVIF, ICO); SVG and non-image `data:` payloads shall be stripped, since DOMPurify cannot inspect bytes inside a `data:` URL. The HTML body shall continue to render inside the sandboxed, script-free iframe of R-2.3. |
| R-2.12 🟩 Done | When the app connects or reconnects to the server — including a returning sign-in (R-1.3) that first paints the mailbox window from the previous session's local cache — the system shall reconcile its active mailbox windows against the server before treating the cached list as current, so newly-delivered mail and messages removed elsewhere appear without a manual refresh (R-3.8). The reconciliation shall use JMAP query-state deltas (`Email/queryChanges`, falling back to a full window rebuild when the server reports it cannot calculate changes) for the most-recently-accessed mailbox windows, and shall always include the Inbox window even when the user last viewed other folders, because the Inbox is the folder opened by default on load. The reconciliation shall be resilient to failures of auxiliary startup syncs: an error fetching identities or contacts shall not prevent the mailbox windows from being reconciled. This requirement covers the connect/reconnect moment; R-3.9 covers the steady-state online case while the user is already connected. |
| R-2.13 🟩 Done | In the folder hierarchy (R-2.1), each folder that has subfolders shall present a disclosure control that expands or collapses its child folders, and the hierarchy shall start fully collapsed so only top-level mailboxes are shown until the user expands a branch. Toggling a folder's disclosure control shall not change the selected folder, and selecting a folder shall not change its expand/collapse state. Each folder's expand/collapse state shall be tracked independently, so collapsing an ancestor hides its subtree without discarding a descendant's retained expanded state. |
| R-2.13 🟩 Done | In the folder hierarchy (R-2.1), each folder that has subfolders shall present a disclosure control that expands or collapses its child folders, and the hierarchy shall start fully collapsed so only top-level mailboxes are shown until the user expands a branch. Toggling a folder's disclosure control shall not change the selected folder, and selecting a folder shall not change its expand/collapse state. Each folder's expand/collapse state shall be tracked independently, so collapsing an ancestor hides its subtree without discarding a descendant's retained expanded state. A collapsed folder's unread count shall be the sum of unread messages across the folder and all folders in its hidden subtree; an expanded folder shall show only its own unread count. |

### 3. Triage

Expand Down
9 changes: 8 additions & 1 deletion src/components/FolderNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ const props = defineProps({
});

const current = computed(() => props.currentFolderId === props.folder.id);
const unread = computed(() => Number(props.folder.unread_emails) || 0);
const iconSvg = computed(() => props.folder.icon);
const hasChildren = computed(() => (props.folder.children?.length ?? 0) > 0);
const collapsed = computed(() => hasChildren.value && props.isCollapsed(props.folder.id));
// A collapsed parent shows the unread total of its whole subtree; an
// expanded or leaf folder shows only its own unread count (children
// surface their own counts when visible).
const unread = computed(() => (
collapsed.value
? Number(props.folder.subtree_unread) || 0
: Number(props.folder.unread_emails) || 0
));
const indent = computed(() => `${10 + (props.folder.depth ?? 0) * 16}px`);
const style = computed(() => ({
paddingLeft: indent.value,
Expand Down
11 changes: 10 additions & 1 deletion src/components/FolderTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,21 @@ const tree = computed(() => {
function build(folder, depth) {
const childrenForFolder = children(folder.id);
const presentation = folderPresentation(folder);
const builtChildren = childrenForFolder.map((c) => build(c, depth + 1));
// Roll the subtree's unread total up to each node so a collapsed
// folder can show the unread count of everything hidden beneath it.
const ownUnread = Number(folder.unread_emails) || 0;
const subtreeUnread = builtChildren.reduce(
(sum, child) => sum + (Number(child.subtree_unread) || 0),
ownUnread,
);
return {
...folder,
depth,
icon: presentation.icon,
tone: presentation.color,
children: childrenForFolder.map((c) => build(c, depth + 1)),
children: builtChildren,
subtree_unread: subtreeUnread,
};
}
return (byParent.get('ROOT') ?? []).map((f) => build(f, 0));
Expand Down
6 changes: 5 additions & 1 deletion src/components/MessageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ const bodyColorScheme = computed(() =>
: effectiveColorScheme.value);

const body = computed(() => mailStore.messageBody);
// null while the body is still loading; a (possibly empty) object once the
// load completes. Used to show the loading placeholder only while loading,
// not for a message that has genuinely no body content.
const bodyLoaded = computed(() => body.value != null);
const referencedInlineContentIds = computed(() => referencedContentIds(body.value?.html ?? ''));

// Render plaintext bodies the way Thunderbird Desktop does: keep the
Expand Down Expand Up @@ -737,7 +741,7 @@ function closeMessageView() {
/>
</div>
<div v-else-if="textHtml" class="message-view__text" v-html="textHtml" />
<p v-else class="message-view__placeholder">Loading message…</p>
<p v-else-if="!bodyLoaded" class="message-view__placeholder">Loading message…</p>
<ul v-if="visibleAttachments.length" class="message-view__attachments">
<li v-for="a in visibleAttachments" :key="a.part_id">
<Paperclip :size="14" :stroke-width="1.75" class="message-view__att-icon" />
Expand Down
7 changes: 6 additions & 1 deletion src/composables/useBodyPrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ export function useBodyPrefetch(deps: BodyPrefetchDeps) {
try {
const body = await repo.getMessageBodyForDisplay(accountId, messageId);
if (token === bodyFetchToken && deps.isSelected(messageId)) {
messageBody.value = body;
// A message with no body parts and no attachments reads back as
// null. Represent that completed-but-empty load as an empty body
// object so the UI can distinguish "loaded, nothing to show" from
// "still loading" (which stays null) instead of showing a
// perpetual loading placeholder.
messageBody.value = body ?? { text: '', html: '', attachments: [] };
}
} catch (err) {
console.warn('[body-prefetch] getMessageBodyForDisplay failed', err);
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/components/folder-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,54 @@ describe('FolderTree collapse/expand', () => {
expect(wrapper.text()).toContain('Gamma');
});
});

describe('FolderTree collapsed unread totals', () => {
// Projects(0) > Alpha(2) > Gamma(3), plus Projects > Beta(5).
// Projects subtree unread = 0 + 2 + 3 + 5 = 10.
function seedUnread(mailStore) {
mailStore.folders = [
makeFolder(1, { name: 'Inbox', role: 'inbox', unread_emails: 1 }),
makeFolder(10, { name: 'Projects', unread_emails: 0 }),
makeFolder(11, { name: 'Alpha', parent_id: 10, unread_emails: 2 }),
makeFolder(12, { name: 'Gamma', parent_id: 11, unread_emails: 3 }),
makeFolder(13, { name: 'Beta', parent_id: 10, unread_emails: 5 }),
makeFolder(20, { name: 'Reports', unread_emails: 0 }),
];
}

it('sums the whole subtree unread on a collapsed parent', async () => {
const mailStore = useMailStore();
seedUnread(mailStore);

const wrapper = mount(FolderTree);
await nextTick();

expect(nodeByName(wrapper, 'Projects').find('.folder-node__count').text()).toBe('10');
// A leaf still shows only its own count.
expect(nodeByName(wrapper, 'Inbox').find('.folder-node__count').text()).toBe('1');
});

it('shows only the own count once expanded, and rolls up at each level', async () => {
const mailStore = useMailStore();
seedUnread(mailStore);

const wrapper = mount(FolderTree);
await nextTick();

await nodeByName(wrapper, 'Projects').find('button.folder-node__toggle').trigger('click');
await nextTick();

// Projects' own unread is 0, so no badge once expanded.
expect(nodeByName(wrapper, 'Projects').find('.folder-node__count').exists()).toBe(false);
// Beta is a leaf: its own 5. Alpha is collapsed: its subtree 2 + 3 = 5.
expect(nodeByName(wrapper, 'Beta').find('.folder-node__count').text()).toBe('5');
expect(nodeByName(wrapper, 'Alpha').find('.folder-node__count').text()).toBe('5');

await nodeByName(wrapper, 'Alpha').find('button.folder-node__toggle').trigger('click');
await nextTick();

// Expanded Alpha now shows only its own 2; Gamma shows its own 3.
expect(nodeByName(wrapper, 'Alpha').find('.folder-node__count').text()).toBe('2');
expect(nodeByName(wrapper, 'Gamma').find('.folder-node__count').text()).toBe('3');
});
});
32 changes: 32 additions & 0 deletions tests/unit/components/message-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,4 +940,36 @@ describe('MessageView HTML body rendering', () => {
// alive only if something else references it.
expect(document.querySelector('iframe.message-view__html-frame')).toBeNull();
});

it('shows the loading placeholder only while the body has not loaded yet', async () => {
// messageBody is null between selecting a message and the body
// load resolving.
await makeSelectedMessage(null);

const wrapper = mount(MessageView, { attachTo: document.body });
await nextTick();

expect(wrapper.find('.message-view__placeholder').exists()).toBe(true);
expect(wrapper.text()).toContain('Loading message');

wrapper.unmount();
});

it('renders an empty body instead of a perpetual loading state when a loaded message has no content', async () => {
// A message with no body parts and no attachments loads as an empty
// body object; the view must drop the loading placeholder and show
// nothing rather than spinning forever.
await makeSelectedMessage({ text: '', html: '', attachments: [] });

const wrapper = mount(MessageView, { attachTo: document.body });
await nextTick();

expect(wrapper.find('.message-view__placeholder').exists()).toBe(false);
expect(wrapper.find('iframe.message-view__html-frame').exists()).toBe(false);
expect(wrapper.find('.message-view__text').exists()).toBe(false);
// The article and its body container still render; the body is empty.
expect(wrapper.find('.message-view__body').exists()).toBe(true);

wrapper.unmount();
});
});
11 changes: 11 additions & 0 deletions tests/unit/composables/useBodyPrefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ describe('useBodyPrefetch', () => {
expect(prefetch.messageBody.value?.text).toBe('body');
});

it('represents a missing/empty body as an empty body object once the load completes', async () => {
// A truly empty message reads back as null from the repo. The
// loader must still record a completed (empty) load so the UI can
// tell it apart from the still-loading null state.
repo.getMessageBodyForDisplay = vi.fn(async () => null);
selected = 7;
const token = prefetch.nextDisplayToken();
await prefetch.loadBodyForDisplay(7, token);
expect(prefetch.messageBody.value).toEqual({ text: '', html: '', attachments: [] });
});

it('drops a stale Email/get when selection moved before the response landed', async () => {
selected = 7;
const stale = prefetch.nextDisplayToken();
Expand Down
Loading