diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml index 1369adbc..2c052a00 100644 --- a/.github/workflows/docker-publish-releases.yml +++ b/.github/workflows/docker-publish-releases.yml @@ -50,6 +50,8 @@ jobs: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 43114ea9..6a718fbb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -78,6 +78,8 @@ jobs: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT=${{ github.sha }} outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e4373ed1..f1fac777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## 1.6.0 (2026-05-01) + +### Features + +- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable +- **Mail**: Image attachment thumbnails and preview chips +- **Mobile**: Reworked mobile mail viewer toolbar +- **Mobile**: Mobile-friendly settings panel +- **Mobile**: Mobile-friendly admin panel +- **Mail**: Redesigned expanded details panel +- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening + +### Fixes + +- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on +- **Navigation**: Scroll apps list in navigation rail to prevent overflow +- **Context menu**: Clamp submenu inside viewport +- **Context menu**: Prevent context menu from clipping below viewport +- **Context menu**: Prevent jump and animation on open +- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195) +- **Mail**: Preserve list scroll position when tagging an email +- **Mail**: Render below-header overflow popup outside clipped row +- **Mail**: Collapse below-header attachments to single row with overflow pill +- **Push**: Fix push preview JMAP query +- **Tour**: Navigate tour to mailbox when starting from another page +- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations + +### i18n + +- Translate SPF/DKIM/DMARC tooltips +- Add missing keys across 14 locales + +## 1.5.4 (2026-05-01) + +### Features + +- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message +- **Composer**: Insert and edit tables in rich-text emails (#236) +- **Mail**: Configurable sub-addressing delimiter character (#239) +- **i18n**: Turkish localization +- **i18n**: Missing keys filled in across 15 locales + +### Fixes + +- **Mail**: Set In-Reply-To and References headers on replies (#234) +- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236) +- **Auth**: Pin JMAP auth verification to the configured server URL (#237) +- **Auth**: Evict unrecoverable basic-auth accounts on reload +- **Notifications**: Scope new-mail notifications to genuine inbox deliveries +- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions +- **Viewer**: Smooth out body load to prevent flicker on first render +- **Viewer**: Prevent iframe flash when loading images or trusting the sender +- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout +- **Viewer**: Light-mode override now only affects body content +- **Viewer**: Detect `${effectiveEmailContent.html}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); + // Imperatively restore blocked external content inside the iframe document. + // Avoids re-rendering the iframe srcDoc (which would reload and flash) when + // the user clicks "Load images" or "Trust sender". + const restoreBlockedContent = useCallback(() => { + const doc = iframeRef.current?.contentDocument; + if (!doc) return; + + doc.querySelectorAll('img[data-blocked-src]').forEach((node) => { + const el = node as HTMLImageElement; + const src = el.getAttribute('data-blocked-src'); + if (src) { + el.setAttribute('src', src); + el.style.display = ''; + el.removeAttribute('data-blocked-src'); + } + }); + + doc.querySelectorAll('[data-blocked-style]').forEach((node) => { + const el = node as HTMLElement; + const style = el.getAttribute('data-blocked-style'); + if (style !== null) { + el.style.cssText = style; + el.removeAttribute('data-blocked-style'); + } + }); + + doc.querySelectorAll('[data-blocked-background]').forEach((node) => { + const el = node as HTMLElement; + const bg = el.getAttribute('data-blocked-background'); + if (bg) { + el.setAttribute('background', bg); + el.removeAttribute('data-blocked-background'); + } + }); + + doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => { + const el = node as HTMLElement; + const style = el.getAttribute('data-blocked-collapsed-style'); + if (style !== null) { + el.style.cssText = style; + el.removeAttribute('data-blocked-collapsed-style'); + } + }); + }, []); + + // Whenever permission is granted (allow toggled, or sender becomes trusted), + // restore blocked content in the existing iframe — no srcDoc rebuild. + const senderEmailLower = email?.from?.[0]?.email?.toLowerCase(); + const senderIsTrustedNow = senderEmailLower + ? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower)) + : false; + useEffect(() => { + if (!hasBlockedContent) return; + if (!allowExternalContent && !senderIsTrustedNow) return; + restoreBlockedContent(); + }, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]); + + // Tracks the last rendered body height so the loading skeleton can hold + // the same size — avoids the body shrink/expand flash when switching emails. + const lastBodyHeightRef = useRef(300); + + // True while the new email's body is still being fetched. Catches the + // window between selectedEmail changing and isLoading flipping true, so the + // quick reply / body don't flicker through a partial render. + const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0; + + // Gates the quick reply on the iframe having loaded the current srcDoc, so + // it doesn't flash in below a still-resizing iframe. + const [iframeReady, setIframeReady] = useState(false); + useLayoutEffect(() => { + setIframeReady(false); + }, [emailIframeSrcDoc]); + const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; @@ -2623,9 +2795,13 @@ export function EmailViewer({ const resizeObserver = new ResizeObserver(() => { const height = doc.documentElement.scrollHeight; iframe.style.height = height + 'px'; + lastBodyHeightRef.current = height; }); resizeObserver.observe(doc.body); - iframe.style.height = doc.documentElement.scrollHeight + 'px'; + const initialHeight = doc.documentElement.scrollHeight; + iframe.style.height = initialHeight + 'px'; + lastBodyHeightRef.current = initialHeight; + setIframeReady(true); // Make links open in new tab doc.querySelectorAll('a').forEach(a => { @@ -2633,6 +2809,27 @@ export function EmailViewer({ a.setAttribute('rel', 'noopener noreferrer'); }); + // Plugin intercept: let plugins cancel or rewrite external links inside + // the email body before navigation happens. Bound on the iframe doc so + // it survives DOM mutations from dark-mode pass below. + const onLinkClick = async (ev: Event) => { + const targetEl = (ev.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null; + if (!targetEl) return; + const href = targetEl.getAttribute('href') || ''; + if (!href || href.startsWith('#') || href.startsWith('mailto:')) return; + ev.preventDefault(); + ev.stopPropagation(); + const ctx = { + href, + target: targetEl.getAttribute('target') ?? undefined, + emailId: email?.id, + }; + const ok = await uiHooks.onBeforeExternalLink.intercept(ctx); + if (!ok) return; + window.open(ctx.href, '_blank', 'noopener,noreferrer'); + }; + doc.addEventListener('click', onLinkClick, true); + // Dark mode: re-invert elements with stylesheet-defined background images // (CSS attribute selectors only catch inline styles, not