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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion e2e/tests/preview-html.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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', '<p>']) {
await expect(preview).not.toContainText(text);
}
});
Expand Down
7 changes: 5 additions & 2 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down