diff --git a/CLAUDE.md b/CLAUDE.md index 4e98de3..e7977bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,7 +170,7 @@ mailboxzero/ 4. **Keyboard:** Shortcuts `F` (Find Similar), `A` (Archive & Find Next), `U` (Undo), `R` (Refresh), `C` (Clear Results) and `Escape` (close the preview or the archive dialog), handled by `handleShortcut()` in `app.js`. They are ignored in a drop-down, with Ctrl/Cmd/Alt, and while the archive dialog is open. The buttons show them in `title` and `aria-keyshortcuts`. Inbox rows are focusable buttons (`aria-pressed` when chosen): Enter or Space chooses one, and ArrowUp/ArrowDown move between inbox rows or between the checkboxes of the similar emails 5. **Performance:** Efficient API calls and client-side caching 6. **Empty Inbox:** An empty inbox shows "Inbox zero!" instead of a blank list, pagination never shows "Page 1 of 0", and a last page that was emptied in the meantime falls back to the new last page (`loadEmails()` in `app.js`) -7. **Email Preview:** Hovering an email in either pane for 300 ms shows a popup with its subject, sender, date and body (`showEmailPreview()` in `app.js`). `loadPreviewBody()` fetches the body from `GET /api/emails/{id}` the first time and keeps it on the email object; it prefers the text body and shows an HTML-only body as plain text. HTML is turned into text by `htmlToText()`, which parses it into a separate document (`DOMParser`) and reads only its text, so a preview never loads an email's images or runs its scripts; never put email HTML into an element of the page, not even a detached one. The Previews checkbox in the action bar turns previews off +7. **Email Preview:** Hovering an email in either pane for 300 ms shows a popup with its subject, sender, date and body (`showEmailPreview()` in `app.js`). `loadPreviewBody()` fetches the body from `GET /api/emails/{id}` the first time and keeps it on the email object; it prefers the text body and shows an HTML-only body as plain text (JMAP lists an HTML-only email's HTML part in `textBody` too, so a `text/html` part there is converted like any other HTML). HTML is turned into text by `htmlToText()`, which parses it into a separate document (`DOMParser`) and reads only its text, so a preview never loads an email's images or runs its scripts; never put email HTML into an element of the page, not even a detached one. The Previews checkbox in the action bar turns previews off ## Configuration Details diff --git a/e2e/tests/preview-html.spec.js b/e2e/tests/preview-html.spec.js index 7e0dcc3..6276e02 100644 --- a/e2e/tests/preview-html.spec.js +++ b/e2e/tests/preview-html.spec.js @@ -30,6 +30,16 @@ const bodies = [ name: 'HTML body values without body parts', body: { bodyValues: { 1: { value: html } } }, }, + { + // JMAP (RFC 8621) lists the only part of an HTML-only email as its text + // body as well as its HTML body. + name: 'an HTML-only email', + body: { + bodyValues: { 1: { value: html } }, + textBody: [{ partId: '1', type: 'text/html' }], + htmlBody: [{ partId: '1', type: 'text/html' }], + }, + }, ]; for (const { name, body } of bodies) { @@ -57,7 +67,7 @@ for (const { name, body } of bodies) { expect(imageRequests).toBe(0); expect(await page.evaluate(() => /** @type {any} */ (window).previewHandlerRan)).toBeUndefined(); expect(await page.evaluate(() => /** @type {any} */ (window).previewScriptRan)).toBeUndefined(); - for (const text of ['Head title', 'head-style', 'body-style', 'previewScriptRan']) { + for (const text of ['Head title', 'head-style', 'body-style', 'previewScriptRan', '
']) { await expect(preview).not.toContainText(text); } }); diff --git a/web/static/app.js b/web/static/app.js index 06e901e..e950e98 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -911,12 +911,15 @@ class MailboxZero { // First try to find text body content using textBody structure if (email.bodyValues && email.textBody && email.textBody.length > 0) { - // Look for text parts first (preferred for preview) + // Look for text parts first (preferred for preview). An email + // with only an HTML body lists that HTML part here too. for (const part of email.textBody) { if (part.partId && email.bodyValues[part.partId]) { const bodyValue = email.bodyValues[part.partId]; if (bodyValue.value && bodyValue.value.trim()) { - bodyContent = bodyValue.value; + bodyContent = part.type === 'text/html' + ? this.htmlToText(bodyValue.value) + : bodyValue.value; break; } }