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 docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ The app connects to Google for mail. AI writing requests go to the selected prov

Signed release builds contact the configured update feed. Attn has no telemetry. Remote images load directly from senders by default. A global block setting and per-sender exceptions control those requests.

**HTML mail rendering:** sanitized (DOMPurify-class allowlist), rendered in a sandboxed `<iframe>`/webview with no script execution, links open in the system browser. Some legitimate senders serve images with `Cross-Origin-Resource-Policy: same-origin`, which Chromium would block inside that frame; the app removes only that response header, only for image requests originating from the mail frame — no other request or header is modified. The frame is measured after load and on resize, preserves horizontal overflow inside the frame, and remains mounted when quote/signature visibility changes. A shared surface classifier gives fallback content and HTML without a non-neutral authored canvas the native Attn treatment; non-neutral backgrounds and background images keep a light document canvas. Native mail clears sender background patches in every palette. Before classification and display, repeated Apple Mail gray lines with white text wrappers use the native reading background. This display-only cleanup requires the matching text-only pattern on at least two nonempty sibling lines; isolated highlights, layout styling, stylesheet-driven documents, and lines inside tables or background containers keep their formatting. Stored mail and outgoing quoted HTML remain unchanged. The existing View original control bypasses this cleanup. It keeps authored inline text colors in light palettes; in dark palettes it adjusts chromatic colors to readable contrast and replaces low-contrast neutral foregrounds with the native default. Constrained sender canvases center within a solid light mail surface. Typography, media, tables, layout attributes, and layout-only CSS are not canvas evidence. The composer quote preview uses the same decision. In the reader, a simple reply above rich collapsed history uses the native background and normal card spacing. The rich history keeps its own light canvas when expanded. This split applies only to ordinary block wrappers with inherited text styles. Stylesheets, shared padding or sizing, and table, list, or flex/grid layouts retain one frame. Both split frames remain scriptless and mounted across quote toggles. View original renders the complete document together. Filename-bearing MIME parts count as attachments whether Gmail supplies an attachment ID or inline base64url data. The stored `inlineData` field is withheld from `ConversationMsg`, and inline-delivered attachments can download without a network request. For `cid:` rendering, however, `mail:getInlineImage` deliberately sends matching image content through the typed preload bridge as an allowlisted-MIME base64 `dataUrl`, capped at 25 MB; the renderer assigns that value to the image in the scriptless mail iframe. A direct MIME Content-ID match takes precedence, with a unique CID or `alt` filename accepted as a compatibility alias when sender HTML and MIME generated different identifiers. Ambiguous and unresolved references remain inert broken-image placeholders.
**HTML mail rendering:** sanitized (DOMPurify-class allowlist), rendered in a sandboxed `<iframe>`/webview with no script execution, links open in the system browser. Some legitimate senders serve images with `Cross-Origin-Resource-Policy: same-origin`, which Chromium would block inside that frame; the app removes only that response header, only for image requests originating from the mail frame — no other request or header is modified. Images with positive numeric width and height attributes keep those authored proportions during responsive sizing, including thin spacer images. Images without both dimensions use their intrinsic proportions. Explicit CSS aspect ratios remain unchanged. The frame is measured after load and on resize, preserves horizontal overflow inside the frame, and remains mounted when quote/signature visibility changes. A shared surface classifier gives fallback content and HTML without a non-neutral authored canvas the native Attn treatment; non-neutral backgrounds and background images keep a light document canvas. Native mail clears sender background patches in every palette. Before classification and display, repeated Apple Mail gray lines with white text wrappers use the native reading background. This display-only cleanup requires the matching text-only pattern on at least two nonempty sibling lines; isolated highlights, layout styling, stylesheet-driven documents, and lines inside tables or background containers keep their formatting. Stored mail and outgoing quoted HTML remain unchanged. The existing View original control bypasses this cleanup. It keeps authored inline text colors in light palettes; in dark palettes it adjusts chromatic colors to readable contrast and replaces low-contrast neutral foregrounds with the native default. Constrained sender canvases center within a solid light mail surface. Typography, media, tables, layout attributes, and layout-only CSS are not canvas evidence. The composer quote preview uses the same decision. In the reader, a simple reply above rich collapsed history uses the native background and normal card spacing. The rich history keeps its own light canvas when expanded. This split applies only to ordinary block wrappers with inherited text styles. Stylesheets, shared padding or sizing, and table, list, or flex/grid layouts retain one frame. Both split frames remain scriptless and mounted across quote toggles. View original renders the complete document together. Filename-bearing MIME parts count as attachments whether Gmail supplies an attachment ID or inline base64url data. The stored `inlineData` field is withheld from `ConversationMsg`, and inline-delivered attachments can download without a network request. For `cid:` rendering, however, `mail:getInlineImage` deliberately sends matching image content through the typed preload bridge as an allowlisted-MIME base64 `dataUrl`, capped at 25 MB; the renderer assigns that value to the image in the scriptless mail iframe. A direct MIME Content-ID match takes precedence, with a unique CID or `alt` filename accepted as a compatibility alias when sender HTML and MIME generated different identifiers. Ambiguous and unresolved references remain inert broken-image placeholders.

**Packaging:** `electron-builder` creates personal and release builds. Personal builds need no release credentials and never check for updates. Release builds require signing, macOS notarization, and explicit distribution metadata. See [the release guide](RELEASE.md).

Expand Down
20 changes: 20 additions & 0 deletions e2e/fixtures/seed-mail-image-sizing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"account": "seed@attn.test",
"threads": [
{
"id": "t-image-sizing",
"messages": [
{
"id": "m-image-sizing",
"labelIds": ["INBOX"],
"receivedDaysAgo": 0,
"receivedAt": "09:00",
"from": "Alert Sender <alert@example.com>",
"to": "seed@attn.test",
"subject": "Alert with a thin image header",
"bodyHtml": "<style>#stylesheet-ratio{aspect-ratio:2 / 1}</style><div id=alert-canvas style=\"width:600px;max-width:100%;background:#005eb8;padding:16px;box-sizing:border-box\"><div style=\"background:white;padding:16px\"><img id=header-image src=\"https://sizing.attn.test/header.gif\" width=600 height=24 style=\"display:block;width:100%\"><img id=spacer-image src=\"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=\" width=600 height=1 style=\"display:block;width:100%\"><p id=alert-copy>Your account alert is ready to read.</p><img id=natural-image src=\"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=\" style=\"width:24px\"><img id=authored-ratio src=\"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=\" width=600 height=1 style=\"width:24px;aspect-ratio:2 / 1\"><img id=stylesheet-ratio src=\"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=\" width=600 height=1 style=\"width:24px\"></div></div>"
}
]
}
]
}
59 changes: 59 additions & 0 deletions e2e/mail-image-sizing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { expect, test } from './electron'
import { runPaletteCommand, threadRow } from './nav'

test.use({ seed: 'fixtures/seed-mail-image-sizing.json' })

for (const appearance of ['light', 'dark'] as const) {
test(`preserves thin email headers and responsive image proportions in ${appearance}`, async ({
page
}, testInfo) => {
let releaseImage!: () => void
const imageGate = new Promise<void>((resolve) => {
releaseImage = resolve
})
await page.route('https://sizing.attn.test/**', async (route) => {
await imageGate
await route.fulfill({
contentType: 'image/gif',
body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64')
})
})
await runPaletteCommand(page, `Use ${appearance === 'dark' ? 'Dark' : 'Light'} theme`)
await threadRow(page, 'Alert with a thin image header').click()
const frame = page.getByTestId('html-body-frame')
const body = page.frameLocator('[data-testid="html-body-frame"]')
const header = body.locator('#header-image')
await expect(header).toHaveCSS('visibility', 'hidden')
await expect(header).toHaveAttribute('data-attn-image-pending', '')
releaseImage()
await expect(header).toHaveCSS('visibility', 'visible')
for (const canvasWidth of [600, 300]) {
await body.locator('#alert-canvas').evaluate((canvas, width) => {
canvas.style.width = `${width}px`
}, canvasWidth)
await expect
.poll(() =>
header.evaluate((image) => {
const bounds = image.getBoundingClientRect()
return Math.abs(bounds.width / bounds.height - 25) < 0.1
})
)
.toBe(true)
expect(
await body.locator('#spacer-image').evaluate((image) => image.getBoundingClientRect().height)
).toBeLessThanOrEqual(1)
await expect.poll(() => frame.evaluate((element) => element.clientHeight)).toBeLessThan(200)
}
await expect(body.locator('#natural-image')).toHaveCSS('height', '24px')
await expect(body.locator('#authored-ratio')).toHaveCSS('height', '12px')
await expect(body.locator('#stylesheet-ratio')).toHaveCSS('height', '12px')
await expect(body.locator('#alert-copy')).toBeVisible()
const directory = join(__dirname, '.artifacts')
mkdirSync(directory, { recursive: true })
const path = join(directory, `mail-image-sizing-${appearance}.png`)
await page.screenshot({ path })
await testInfo.attach(`mail-image-sizing-${appearance}`, { path, contentType: 'image/png' })
})
}
9 changes: 8 additions & 1 deletion src/renderer/src/mailFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function frameReset({ surface, layout, appearance, scrollable }: MailFramePresen
color: #60a5fa !important;
}`
}
img { max-width: 100%; height: auto; }
img { max-width: 100%; height: auto; aspect-ratio: var(--attn-mail-image-ratio, auto); }
img[${MAIL_IMAGE_PENDING_MARKER}] { visibility: hidden !important; }
img[data-remote-blocked="true"] { visibility: hidden !important; }
table { max-width: 100%; }
Expand Down Expand Up @@ -267,6 +267,13 @@ export function mailFrameDocument({
if (blockRemoteImages) suppressBlockedRemoteImages(template.content, TRANSPARENT_IMAGE)
replaceCidSources(template.content, inlineImages)
template.content.querySelectorAll('img').forEach((image) => {
// The responsive height reset must use the sender's dimensions. Using a
// spacer's intrinsic 1:1 ratio can turn a wide, thin header into a square.
const width = Number(image.getAttribute('width'))
const height = Number(image.getAttribute('height'))
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
image.style.setProperty('--attn-mail-image-ratio', `${width} / ${height}`)
}
image.setAttribute(MAIL_IMAGE_PENDING_MARKER, '')
})
const trimMatch = allowTrim ? findHtmlTrimStart(template.content) : null
Expand Down
Loading