diff --git a/.dockerignore b/.dockerignore index 771c8d7de..509fe74a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,8 @@ scripts/ TODO.md *.md !README.md +# Sibling projects / test harness - not part of the webmail image +examples/ +integration/ +e2e/ +**/node_modules diff --git a/.env.dev.example b/.env.dev.example index 6d5d44d61..d26b9f149 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true LOG_FORMAT=text LOG_LEVEL=debug +# ============================================================================= +# Plugin Development +# ============================================================================= + +# Load plugins from a directory on disk instead of installing them as ZIPs. +# Each immediate subfolder is one plugin and needs a manifest.json. When the +# manifest's entrypoint exists under src/, it's bundled on demand with esbuild, +# so you can edit sources and just refresh the browser. +# PLUGIN_DEV_DIR=../my-plugins + # ============================================================================= # Login Page Customization (optional) # ============================================================================= @@ -47,3 +57,8 @@ LOG_LEVEL=debug # LOGIN_IMPRINT_URL=https://example.com/imprint # LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy # LOGIN_WEBSITE_URL=https://example.com + +# Per-domain branding overrides. Each entry must have "host" (exact or +# "*.subdomain" wildcard) plus any subset of branding fields to override. +# Unset fields fall through to the global values above. +# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}] diff --git a/.env.example b/.env.example index 5f21140e4..065c9f924 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Access-Control-Allow-Origin header, or browser requests will be blocked. # ALLOW_CUSTOM_JMAP_ENDPOINT=true +# Offer several JMAP servers on the login form. JSON array; each entry needs +# id, label, and url. "domains" and a per-server "oauth" block are optional. +# Prefer configuring this from the admin dashboard - the env form exists for +# stateless deployments. +# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}] + +# Pick the server automatically from the domain of the address the user types, +# matching against each entry's "domains" list. Default: false. +# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true + # ============================================================================= # Stalwart Mail Server Integration # ============================================================================= @@ -49,11 +59,29 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OpenID Connect issuer URL for discovery # OAUTH_ISSUER_URL=https://your-idp.example.com +# Overrides only the user-facing authorize endpoint (e.g. a per-brand login +# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL. +# Leave unset to use the authorization_endpoint from discovery. +# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/ + # Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses. # Off by default as an SSRF guard. Enable for split-DNS deployments where the # OAuth issuer's public hostname resolves to an internal IP from this server. # OAUTH_ALLOW_PRIVATE_ENDPOINTS=true +# Replace the scopes requested at authorization. Space-separated. Leave unset +# to use the defaults the client already asks for. +# OAUTH_SCOPES=openid email profile offline_access + +# Append scopes instead of replacing them. Use this when your IdP needs one +# extra scope and you don't want to restate the defaults. +# OAUTH_EXTRA_SCOPES=groups + +# Send the user straight to the identity provider, skipping the login form. +# Intended for embedded deployments where the parent app already authenticated +# them. Default: false. +# AUTO_SSO_ENABLED=true + # ============================================================================= # Session & Security # ============================================================================= @@ -110,12 +138,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Anonymous Telemetry # ============================================================================= -# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII: -# version, platform, bucketed account counts, and feature toggles only. See +# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it +# helps us understand how Bulwark is used so we can make the product better. +# Heartbeats contain no PII: version, platform, bucketed account counts, and +# feature toggles only - never email addresses, hostnames, or IPs. See # https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema. # -# Disable telemetry entirely (overrides the admin UI): -# BULWARK_TELEMETRY=off +# Enable telemetry (also toggleable in the admin UI): +# BULWARK_TELEMETRY=on +# +# Setting this (on or off) locks the choice and disables the admin UI toggle. # Directory for telemetry state: instance id, consent, login HMACs # (default: ./data/telemetry). For Docker, the default resolves to @@ -123,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com # so the instance id and consent choice survive upgrades. # TELEMETRY_DATA_DIR=./data/telemetry +# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset. +# BULWARK_TELEMETRY_DISABLED=1 + +# Let heartbeats reach a private/loopback address. Off by default as an SSRF +# guard; only useful when running a collector locally during development. +# BULWARK_TELEMETRY_ALLOW_PRIVATE=1 + +# Report a fixed Stalwart version instead of probing the JMAP server's Server +# header. Useful when a proxy strips that header. +# STALWART_VERSION=0.16.0 + # ============================================================================= # Server Listen Address # ============================================================================= @@ -188,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Should match your app's main background color. Default: #ffffff # PWA_BACKGROUND_COLOR=#ffffff +# Screenshots shown in the browser's install prompt. Absolute URLs or paths +# relative to public/. Both are optional; per-domain overrides are available +# through DOMAIN_BRANDING. +# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png +# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png + # --------------------------------------------------------------------------- # Logos # --------------------------------------------------------------------------- @@ -225,6 +274,46 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# Cap the login logo's rendered size. Any CSS length ("120px", "8rem"). +# Unset means the logo renders at its natural size. +# LOGIN_LOGO_MAX_HEIGHT=96px +# LOGIN_LOGO_MAX_WIDTH=320px + +# Hide parts of the login page. All default to true. +# Turn the heading and subtitle off when the logo already reads as the brand. +# LOGIN_SHOW_HEADING=false +# LOGIN_SHOW_SUBTITLE=false +# +# Hide the optional TOTP field. A server that requires TOTP (totp_required) +# still shows it regardless of this setting. +# LOGIN_SHOW_TOTP=false +# +# Hide the version number, so it isn't disclosed to unauthenticated visitors. +# LOGIN_SHOW_VERSION=false + +# --------------------------------------------------------------------------- +# Per-domain branding overrides (optional) +# --------------------------------------------------------------------------- +# +# When you serve the webmail on multiple hostnames, each hostname can override +# a subset of branding fields. Unset fields fall back to the global values +# above. Match is on the request's Host (or X-Forwarded-Host) header. +# +# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com" +# matches mail.example.com and any deeper subdomain, but NOT example.com). +# Exact matches always win over wildcards; the longest wildcard suffix wins +# among multiple wildcard matches. +# +# Overridable keys: appName, appShortName, appDescription, faviconUrl, +# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl, +# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, +# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl. +# +# Prefer setting this from the admin dashboard (PATCH /api/admin/config). +# The env-var form is provided for stateless deployments. +# +# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}] + # ============================================================================= # Extension Directory / Marketplace # ============================================================================= @@ -234,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # your own directory (e.g. http://localhost:3001 for local development). # EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org +# ============================================================================= +# Admin Dashboard Access +# ============================================================================= + +# Bootstrap password for the admin dashboard. Read only when admin.json does +# not already exist; the app hashes it, writes admin.json, and logs a warning +# telling you to remove this variable. Without it (and without the setup +# wizard) the admin dashboard stays disabled. +# Accepts a plaintext password or an existing hash. +# ADMIN_PASSWORD=change-me + +# Admin session lifetime in seconds. Default: 3600 (1 hour). +# ADMIN_SESSION_TTL=3600 + +# How many trusted reverse proxies sit in front of the app. The client IP is +# taken that many entries from the right of X-Forwarded-For, so an attacker +# can't spoof it by prepending values. Default: 1. +# TRUSTED_PROXY_DEPTH=2 + +# Allow search engines to index the app (robots.txt / noindex). Default: false. +# SEARCH_ENGINE_INDEXING=true + +# ============================================================================= +# Cookies, Embedding & Reverse Proxies +# ============================================================================= + +# SameSite attribute for session cookies: lax (default), strict, or none. +# Embedding the app cross-origin in an iframe requires "none". +# COOKIE_SAME_SITE=none + +# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or +# COOKIE_SAME_SITE=none. Set to false only for local HTTP development. +# COOKIE_SECURE=false + +# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none', +# which blocks all framing. Space-separate multiple origins. +# ALLOWED_FRAME_ANCESTORS=https://portal.example.com + +# Origin of the parent page when embedded, used for postMessage handshakes. +# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com + +# ============================================================================= +# Update Check +# ============================================================================= + +# The app periodically checks for new releases and shows a notice. Set to +# "off" (or false/0/no) to disable the check entirely. +# BULWARK_UPDATE_CHECK=off + +# Override the endpoint it checks. Takes priority over the on-disk state file. +# An explicit empty value also disables the check. +# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json + +# Where the check stores its state. Default: ./data/version-check +# VERSION_CHECK_DATA_DIR=./data/version-check + +# ============================================================================= +# Translation Proxy (optional) +# ============================================================================= + +# /api/translate defaults to the public MyMemory API, which needs no setup. +# Point it at a LibreTranslate instance instead to keep message text on +# infrastructure you control. LibreTranslate also auto-detects the source +# language natively. +# LIBRETRANSLATE_URL=https://libretranslate.example.com +# LIBRETRANSLATE_API_KEY= + +# ============================================================================= +# Web Push +# ============================================================================= + +# Push notifications go through a hosted relay so self-hosters don't need +# their own VAPID keys and Firebase project. Point this at your own relay to +# avoid the default. Build-time variable. +# Default: https://notifications.relay.bulwarkmail.org +# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com + +# ============================================================================= +# Demo Mode +# ============================================================================= + +# Serve fixture data instead of talking to a mail server. Default: false. +# DEMO_MODE=true + +# ============================================================================= +# Stalwart Impersonation (advanced) +# ============================================================================= + +# Lets a trusted platform mint a JWT that logs a user in without their +# password, using a Stalwart master account. Intended for embedded +# deployments where an outer platform already authenticated the user. +# +# SECURITY: this grants sign-in as any mailbox on the server. The endpoint +# returns 404 unless all three required variables below are set, so leaving +# them unset keeps the feature fully off. Treat the secret and the master +# password as you would a root credential. +# +# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters +# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com +# BULWARK_STALWART_MASTER_PASSWORD= # required +# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail" + # ============================================================================= # Internationalization # ============================================================================= @@ -242,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # # Fallback UI locale used when the visitor's Accept-Language header does not # match any supported locale. Defaults to "en". -# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh +# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, +# pt, ro, ru, sk, tr, uk, zh +# An unsupported value falls back to "en". # NEXT_PUBLIC_DEFAULT_LOCALE=tr # Locale prefix mode for URLs. Recommended "always" when proxying under a diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml index 2c052a004..031bc5492 100644 --- a/.github/workflows/docker-publish-releases.yml +++ b/.github/workflows/docker-publish-releases.yml @@ -118,3 +118,114 @@ jobs: - name: Inspect image run: | docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + build-always: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT=${{ github.sha }} + NEXT_PUBLIC_LOCALE_PREFIX=always + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=always-${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }} + + - name: Export digest + run: | + mkdir -p /tmp/digests-always + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests-always/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests-always/* + if-no-files-found: error + retention-days: 1 + + merge-always: + runs-on: ubuntu-latest + needs: build-always + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests-always + pattern: digests-always-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + flavor: | + suffix=-always,onlatest=true + tags: | + type=raw,value=latest + type=semver,pattern=v{{version}} + type=semver,pattern={{version}} + type=semver,pattern=v{{major}}.{{minor}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern=v{{major}} + type=semver,pattern={{major}} + + - name: Create manifest list and push + working-directory: /tmp/digests-always + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f233e76dc..3a1c16b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,427 @@ # Changelog +## 1.8.1 (2026-08-07) + +A GitHub Actions incident left the 1.8.0 release build queued indefinitely, so no Docker image was ever published for that tag. 1.8.1 re-runs the release with the same code, plus one plugin slot that merged in the meantime. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- [@getpankajyadav](https://github.com/getpankajyadav) +- [@Beckid](https://github.com/Beckid) +- [@schnz](https://github.com/schnz) +- [@BryanBerger98](https://github.com/BryanBerger98) + +**Monthly** + +- [@zeddD1abl0](https://github.com/zeddD1abl0) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) + +### Features + +- **Mail**: Nest tags in a tree by picking a parent when you create one +- **Mail**: Per-tag visibility — always, only when unread, or always hidden +- **Mail**: Assign and clear several tags at once, with a reworked tag display +- **Mail**: Recover tags from the server by scanning mail for JMAP keywords no local tag explains (#658) +- **Mail**: "Forward as attachment" in the viewer and the message-list context menu +- **Mail**: Refresh button in the mail-list toolbar +- **Composer**: Confirm sending without a subject instead of blocking the send, with "Don't ask again" (#684) +- **Contacts**: Contact cards for organizations (#701) +- **Security**: Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings +- **Notifications**: Background notification onboarding, sequenced after the PWA install prompt +- **Navigation**: Deep links for mail, calendar, contacts, files, and settings, with screen-reader improvements +- **Settings**: Always show the Unified Mailbox switch in Layout settings +- **i18n**: Catalan translation +- **i18n**: Localized editor toolbar across every locale +- **Plugins**: Contact API — `contact.get`, `contact.create`, `contact.update`, `contact.search` +- **Plugins**: `contact-cryptokeys` UI slot, behind the `ui:contact-cryptokeys` permission, so a plugin can render a contact's crypto keys in place of the built-in list +- **Plugins**: `user.getAccounts` and `user.getIdentities` +- **Plugins**: `user.logout` method and logout hook +- **Plugins**: Crypto API — public-key management and encryption-at-rest control on the privileged tier +- **Plugins**: `onBeforeBlobUpload` can offload an attachment to external storage +- **Plugins**: Binary `Blob`/`File` bodies for `api.http.post` +- **Plugins**: `upfiles.get` moved behind `email:blob-read`, off the privileged tier +- **Dev**: Mock JMAP defaults now include nested tags + +### Changes + +- **Mail**: The "Reset to defaults" button is gone from tag settings — one stray click wiped a carefully built tag list, with no confirmation and no undo + +### Fixes + +- **Send**: Send through the identity's own account client so DKIM matches the From domain (#461) +- **Send**: Split `Name ` recipients into the JMAP `name` and `email` fields (#672) +- **Send**: Time out stalled JMAP requests so a send can't hang forever (#702) +- **Mail**: Keep inline images when replying to `application/octet-stream` cid parts (#543) +- **Mail**: Reply on your own thread message no longer re-addresses the original recipients (#703) +- **Mail**: Empty folder no longer stops after 500 emails (#711) +- **Mail**: Move messages across accounts from the "Move to" context menu, preserving read state and deferring source removal to Stalwart +- **Mail**: Stop resurrecting deleted rows in the mailbox refresh merge +- **Mail**: Keep the `message/rfc822` attachment visible after inline unwrapping +- **Mail**: Strip sender and recipient names from forward-as-attachment filenames +- **Mail**: Open `mailto:` links in the built-in composer +- **Mail**: Spell out the full tag path in drag-and-drop toasts, so `Personal/Receipts` and `Work/Receipts` no longer read as the same tag +- **Mail**: Act on current email state in the context menu's mark-as-read instead of a stale copy +- **Mail**: Match the selected-row tint between dark and light mode +- **Mail**: Restore lost animations after the Tailwind config move +- **Drafts**: Restore the sender identity when reopening a draft +- **Calendar**: Stop re-adding the organizer to the attendee list on every save (#731) +- **Calendar**: Stop re-probing shared accounts that have no calendar access +- **Calendar**: Route the parse dump through the debug logger +- **Contacts**: Stop minting duplicate "Trusted Senders" address books (#730) +- **Contacts**: Hide Contacts and Calendars when the account lacks the JMAP capability +- **Contacts**: Require an explicit shared-account fallback for contacts and calendars +- **Files**: Show the modification date instead of the creation date (#700) +- **PWA**: Honor the configured theme color in the desktop title bar and keep it in step with the active theme (#671) +- **Accounts**: Reconcile the stale persisted account chip after an impersonation handoff +- **Auth**: Only request a credential cookie when the server has a `SESSION_SECRET` +- **JMAP**: Split requests to stay inside the server's advertised limits — `maxCallsInRequest`, `maxObjectsInGet`/`InSet`, `maxSizeRequest`, and concurrency +- **JMAP**: Treat an aborted SSE connect as a close, not a failure +- **JMAP**: Surface the underlying network error cause in passthrough failures +- **Settings**: Avoid leaving `TZ="undefined"` when restoring an unset timezone +- **Plugins**: Stop a privileged plugin from reading another privileged plugin's PRF secret +- **Plugins**: Correct the method names for message errors and `crypto.getPublicKeys` +- **i18n**: Restore key parity across locales and the English `send_timeout` string +- **Docs**: Document the remaining env vars in the env templates, and correct the facts and headings in README and FEATURES + +## 1.8.0 (2026-08-06) + +Superseded by 1.8.1. The release build never produced a Docker image — use 1.8.1 instead. The full notes live under 1.8.1 above. + +## 1.7.8 (2026-07-22) + +### Features + +- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509) +- **Unified Mailbox**: Search in the unified views +- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts +- **Mail**: Message-list category tabs +- **Mail**: Drag-and-drop reorder for all folders +- **Mail**: Collapse quoted reply text behind a "..." toggle (#480) +- **Mail**: Bulk Not-Spam action in the junk selection toolbar +- **Mail**: Unread count badge on the favicon +- **Mail**: Message spacing setting (auto/always/edge-to-edge) +- **Mail**: Open external links in a new tab (safely) +- **Mail**: Strip external `url()`/`@import` from `${effectiveEmailContent.html}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); +${effectiveEmailContent.html}`; + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); - // 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]); + // Unblocking external content is handled by rebuilding the iframe srcDoc: + // toggling allowExternalContent (both "Load images" and "Trust sender" set + // it) recomputes emailContent without blocking and swaps the strict CSP for + // the permissive one. In-place restore isn't possible because a document's + // CSP is fixed at load — the strict blocking-mode CSP would keep refusing the + // restored URLs. // Tracks the last rendered body height so the loading skeleton can hold // the same size - avoids the body shrink/expand flash when switching emails. @@ -2945,8 +2254,20 @@ export function EmailViewer({ // 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); + // Tracks which parsed document we've already wired up, so setup runs exactly + // once per srcDoc even though both the readiness poll below and the iframe + // 'load' event can trigger it. + const initializedDocRef = useRef(null); + // The document present at the instant srcDoc changed - i.e. the one about to + // be torn down. contentDocument keeps pointing at it until the browser swaps + // the new srcDoc in, so the poll skips it to avoid wiring up stale content. + const staleDocRef = useRef(null); useLayoutEffect(() => { setIframeReady(false); + initializedDocRef.current = null; + // Runs during commit, before the browser processes the new srcDoc, so + // contentDocument here is still the outgoing document. + staleDocRef.current = iframeRef.current?.contentDocument ?? null; }, [emailIframeSrcDoc]); const handleIframeLoad = useCallback(() => { @@ -2954,25 +2275,72 @@ export function EmailViewer({ if (!iframe) return; try { const doc = iframe.contentDocument; - if (doc?.body) { + // Ignore the outgoing document, a transient about:blank (a fresh srcDoc + // document reports URL 'about:srcdoc'), and anything that hasn't finished + // parsing yet; run the setup below at most once per document. + if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return; + if (initializedDocRef.current === doc) return; + initializedDocRef.current = doc; + { + // Collapse the quoted original of a reply behind a "•••" toggle + // (#480). Before the height wiring, so the initial measurement + // already reflects the collapsed body. + setupQuoteCollapse(doc, { + show: t('show_quoted_text'), + hide: t('hide_quoted_text'), + }); + // Auto-resize iframe to fit content - const resizeObserver = new ResizeObserver(() => { - const height = doc.documentElement.scrollHeight; + // Measure max(documentElement, body): a height:100% wrapper can leave + // documentElement.scrollHeight short while the real content lives in body. + const applyHeight = () => { + if (iframe.contentDocument !== doc) return; // navigated away; stale + const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight); iframe.style.height = height + 'px'; lastBodyHeightRef.current = height; - }); + }; + const resizeObserver = new ResizeObserver(applyHeight); resizeObserver.observe(doc.body); - const initialHeight = doc.documentElement.scrollHeight; - iframe.style.height = initialHeight + 'px'; - lastBodyHeightRef.current = initialHeight; + applyHeight(); + // The ResizeObserver only fires on body's border box; a content overflow + // that grows scrollHeight without resizing that box (e.g. a height:100% + // wrapper, or images that reflow the layout after onload) is otherwise + // missed and the iframe stays short. Re-measure on a fixed cadence over a + // short settle window, then stop — a self-clearing catch-all that does + // not depend on image load/error events firing (blocked images may fire + // neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to + // mis-trigger on a brief-stable-then-grow reflow. + const poll = window.setInterval(() => { + if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; } + applyHeight(); + }, 200); + window.setTimeout(() => window.clearInterval(poll), 2400); setIframeReady(true); - // Make links open in new tab - doc.querySelectorAll('a').forEach(a => { - a.setAttribute('target', '_blank'); - a.setAttribute('rel', 'noopener noreferrer'); + // Hide images that fail to load (dead/mixed-content/unreachable external + // URLs) rather than leaving the browser's broken-image placeholder and + // alt text, which read as stray label text in an otherwise image-only + // email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1 + // transparent pixel (naturalWidth 1) and display:none, so they're skipped. + const hideIfBroken = (img: HTMLImageElement) => { + if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) { + img.style.display = 'none'; + } + }; + doc.querySelectorAll('img').forEach((el) => { + const img = el as HTMLImageElement; + if (img.complete) { + hideIfBroken(img); + } else { + img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true }); + img.addEventListener('load', () => hideIfBroken(img), { once: true }); + } }); + // Second pass over the rendered iframe DOM (the hook above only sees + // DOMPurify's output); http(s) → new tab, other schemes left in place. + doc.querySelectorAll('a').forEach(applyNewTabToAnchor); + // 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. @@ -3009,10 +2377,13 @@ export function EmailViewer({ if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return; const computed = win.getComputedStyle(htmlEl); if (computed.backgroundImage && computed.backgroundImage !== 'none') { - // Only re-invert if this container doesn't have media children - if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) { - htmlEl.style.filter = 'invert(1) hue-rotate(180deg)'; - } + // Re-invert so the background image returns to its original + // appearance. Matches the CSS background-image rules: re-invert + // even when media is nested, then cancel the per-image + // re-invert on that media so it isn't left double-inverted. + htmlEl.style.filter = 'invert(1) hue-rotate(180deg)'; + htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]') + .forEach(m => { (m as HTMLElement).style.filter = 'none'; }); } }); @@ -3090,7 +2461,27 @@ export function EmailViewer({ } catch { // Cross-origin restrictions - iframe will still display content } - }, [isDark, emailHasNativeDarkMode, email?.id]); + }, [isDark, emailHasNativeDarkMode, email?.id, t]); + + // Wire up the iframe as soon as its sandboxed document has parsed, rather than + // waiting for the iframe 'load' event. 'load' also waits on every subresource, + // so a single unreachable remote image (server accepts the TCP connection but + // never responds) stalls it for the browser's ~60s timeout - freezing the body + // at its placeholder height that entire time. The parsed DOM we need for + // height, links and dark-mode is ready long before images resolve. Poll the + // fresh document's readyState because a sandbox without allow-scripts can't + // postMessage a DOMContentLoaded signal out, and the onLoad handler is + // idempotent per document so it stays a harmless backstop. + useEffect(() => { + if (!iframeRef.current) return; + const readyPoll = window.setInterval(() => { + handleIframeLoad(); + if (initializedDocRef.current) window.clearInterval(readyPoll); + }, 50); + // Safety stop: the 'load' backstop covers anything the poll somehow misses. + const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000); + return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); }; + }, [emailIframeSrcDoc, handleIframeLoad]); // Export email as .eml file const handleExportEmail = async () => { @@ -3219,6 +2610,106 @@ export function EmailViewer({ ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) : false; + // ── Read receipt (MDN, RFC 8098) ────────────────────────────── + // Detect a Disposition-Notification-To request on the open message. The + // header is parsed into email.headers by the client; look it up + // case-insensitively and extract the bare address. + const readReceiptRequestedBy = useMemo(() => { + const headers = email?.headers as Record | undefined; + if (!headers) return null; + let raw: string | undefined; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'disposition-notification-to') { + const v = headers[key]; + raw = Array.isArray(v) ? v[0] : v; + break; + } + } + if (!raw) return null; + const m = raw.match(/<([^>]+)>/); + const addr = (m ? m[1] : raw).trim(); + return addr || null; + }, [email?.headers]); + + // The identity whose address received the original message — the MDN is sent + // "from" that address. Falls back to the primary identity. + const receiptIdentity = useMemo(() => { + if (!identities?.length) return null; + const recipients = [...(email?.to || []), ...(email?.cc || [])] + .map(r => r.email?.toLowerCase()) + .filter(Boolean); + return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0]; + }, [identities, email?.to, email?.cc]); + + const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true; + const [mdnHandledLocally, setMdnHandledLocally] = useState(false); + useEffect(() => { setMdnHandledLocally(false); }, [email?.id]); + + // Only offer the receipt for mail you're actually reading in a "received" + // location. Suppress your own copies (sent/drafts), discarded mail (trash), + // and spam (junk) - never confirm your address to spammers. Inbox, Archive + // and user folders all qualify. + const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || ''); + + const shouldOfferReadReceipt = + !!readReceiptRequestedBy && + !mdnAlreadyHandled && + !mdnHandledLocally && + readReceiptResponse !== 'never' && + inReceiptEligibleFolder && + !isDraft && + !!receiptIdentity; + + const sendReadReceiptNow = useCallback(async (automatic: boolean) => { + if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return; + const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id); + try { + await client.sendReadReceipt({ + to: readReceiptRequestedBy, + fromEmail: receiptIdentity.email, + fromName: receiptIdentity.name, + identityId: rawId ?? receiptIdentity.id, + originalMessageId: email.messageId, + originalSubject: email.subject, + originalRecipient: receiptIdentity.email, + automatic, + subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }), + humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }), + }); + await client.setKeyword(email.id, '$mdnsent'); + } catch (err) { + // Surface the failure instead of silently resetting the banner so we can + // see which step (upload / import / submission) failed. + console.error('Read-receipt (MDN) send failed:', err); + toast.error(t('read_receipt.send_failed'), { + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, [client, email, readReceiptRequestedBy, receiptIdentity, t]); + + const ignoreReadReceipt = useCallback(async () => { + setMdnHandledLocally(true); + if (client && email) { + // $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the + // request is suppressed everywhere - not just locally. + try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ } + } + }, [client, email]); + + // "always" mode: auto-send the MDN once when the message is opened. + const autoMdnRef = useRef(null); + useEffect(() => { + if (readReceiptResponse !== 'always') return; + if (!shouldOfferReadReceipt || !email) return; + if (autoMdnRef.current === email.id) return; + autoMdnRef.current = email.id; + sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; }); + // email is already captured via email?.id and sendReadReceiptNow (which + // depends on `email`); the autoMdnRef guard prevents a double send. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // Show loading skeleton while email is being fetched if (isLoading && !email) { return ( @@ -3285,7 +2776,7 @@ export function EmailViewer({

{tDemoWelcome('title')}

{tDemoWelcome('description')}

-
+
{tDemoWelcome('feature_email')} @@ -3327,7 +2818,7 @@ export function EmailViewer({

{t('no_conversation_description')}

{onCompose && ( )} @@ -3352,17 +2843,49 @@ export function EmailViewer({ variant="ghost" size="icon" onClick={onBack} - className="h-9 w-9 flex-shrink-0 -ml-1" + className="h-9 w-9 flex-shrink-0 -ms-1" aria-label={t('back_to_list')} > )} - {isDraft && onEditDraft && ( + {isScheduled && canCancelScheduled && ( + <> + + + + + )} + {!isScheduled && isDraft && onEditDraft && ( )} - {!isDraft && (<> + {!isScheduled && !isDraft && (<> {moveMenuOpen && ( -
+
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -3461,8 +2995,9 @@ export function EmailViewer({
{isTarget ? ( {tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); }} + />
)}
{/* Spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + )} {/* More menu - click-based */}
{moreMenuOpen && !isMobile && ( -
+