git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commitad6c56a7cf728c97d0ce149559e8cf945f912675
parenta82cac33ea
authorLucas Galante <[email protected]>
date2026-08-31 09:04
feat: HTML mail rendering via embedded WPE WebKit

The detail pane now renders text/html mail with the real engine instead of
stripping it to text. The embedding follows cce-browser's WPE port —
subclass.rs / glib_source.rs / input.rs / wrapper.h are verbatim copies of
its src/wpe/ (kept byte-identical for a future shared-crate extraction) —
while host.rs replaces the tabbed WebKitHost with MailWebView, one view
locked down for hostile content: JavaScript off, an ephemeral network
session, every remote load blocked by a compiled content filter (data:
stays for inline images), and navigation intercepted so links open through
xdg-open instead of in-pane. WebKit's processes only spawn once a message
actually renders, so a text-only session pays nothing.

The full HTML part is fetched on demand over IMAP (the sync caches only a
1200-char text preview): BODYSTRUCTURE walked fresh each time — works for
mail cached before this feature — then the first text/html part, capped at
1 MiB, decoded by mail-parser, and kept in a small id-keyed cache that the
sync merge clears (ids can re-key). Two right-aligned chips join the
detail header band: View HTML/Text, and a per-message Load Images that
lifts the remote-content block (re-armed on every message switch).

Input routes to the page only while a frame is on show: wheel and page
keys scroll the document, presses select text (release follows the
pointer out of the pane), Ctrl+C copies the page selection, and the
list's Up/Down selection keys stay with the app. The GLib main context
wakes calloop through the same epoll bridge + re-arming timer as the
browser, settling at a 1s tick when idle.

Feature 'wpe', default on (the sweep-rebuild lesson); a
--no-default-features build needs no WPE headers and stays text-only.
examples/wpe_mail.rs proves boot -> load_html -> frames -> pixel color
headlessly, and CCE_MAIL_HTML_DEMO=1 renders mock bodies through the
pipeline so a shadow session can verify in-app without credentials.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml             |  23 ++
 build.rs               |  54 ++++
 examples/wpe_mail.rs   |  42 +++
 src/main.rs            | 648 ++++++++++++++++++++++++++++++++++++++++++++--
 src/wpe/glib_source.rs | 139 ++++++++++
 src/wpe/host.rs        | 680 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/wpe/input.rs       | 118 +++++++++
 src/wpe/mod.rs         |  23 ++
 src/wpe/subclass.rs    | 307 ++++++++++++++++++++++
 src/wpe/wrapper.h      |   5 +
 10 files changed, 2023 insertions(+), 16 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 08c157f..3f767cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,17 @@
 name = "cce-mail"
 version = "0.1.0"
 edition = "2021"
+# Only runs bindgen when the `wpe` feature is on, so a featureless build
+# needs no WPE headers.
+build = "build.rs"
+
+[features]
+# HTML mail rendering via embedded WPE WebKit (the same engine and embedding
+# pattern as cce-browser's default build — see src/wpe/). Default, so a
+# routine sweep rebuild ships the HTML view instead of silently reverting to
+# text-only. Needs `pacman -S wpewebkit`.
+default = ["wpe"]
+wpe = ["dep:rustix"]
 
 [dependencies]
 keyring = { version = "3", features = ["sync-secret-service"] }
@@ -20,4 +31,16 @@ native-tls = "0.2"
 reqwest = { version = "0.12", features = ["json"] }
 mail-parser = "0.11.5"
 imap-proto = "0.10"
+# Only used by the `wpe` backend, to hold GLib's changing pollfd set in one
+# epoll fd that calloop can watch (same bridge as cce-browser's).
+rustix = { version = "0.38", features = ["event"], optional = true }
+
+[build-dependencies]
+bindgen = "0.72"
+pkg-config = "0.3"
+
+# Headless smoke test for the embedded webview (no Wayland session needed).
+[[example]]
+name = "wpe_mail"
+required-features = ["wpe"]
 
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..24180e1
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,54 @@
+//! Generates the WPE WebKit FFI bindings, but **only under the `wpe` feature**.
+//!
+//! Same shape as cce-browser's build.rs (the embedding pattern this crate's
+//! `src/wpe/` follows): without the feature this is a no-op, so a
+//! `--no-default-features` build needs no WPE headers at all.
+
+fn main() {
+    println!("cargo:rerun-if-changed=build.rs");
+    println!("cargo:rerun-if-changed=src/wpe/wrapper.h");
+    if std::env::var("CARGO_FEATURE_WPE").is_err() {
+        return;
+    }
+
+    // Both modules are needed: wpe-webkit-2.0 is the engine, wpe-platform-2.0
+    // is the embedding layer (WPEDisplay / WPEView / WPEToplevel) that
+    // src/wpe/subclass.rs subclasses. pkg-config emits the link flags for us.
+    let mut clang_args = Vec::new();
+    for module in ["wpe-webkit-2.0", "wpe-platform-2.0"] {
+        let lib = pkg_config::Config::new()
+            .probe(module)
+            .unwrap_or_else(|e| panic!("`{module}` not found — pacman -S wpewebkit ({e})"));
+        for path in &lib.include_paths {
+            clang_args.push(format!("-I{}", path.display()));
+        }
+    }
+
+    let bindings = bindgen::Builder::default()
+        .header("src/wpe/wrapper.h")
+        .clang_args(&clang_args)
+        // The engine, the embedding layer, and just enough GObject to
+        // register subclasses and turn a main loop.
+        .allowlist_item("(wpe|WPE|webkit|WebKit)_?.*")
+        .allowlist_item("g_(object|type|signal|bytes|timeout|free|error)_.*")
+        // The main loop AND the context: `pump` drains the context directly.
+        .allowlist_item("g_main_(loop|context)_.*")
+        .allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
+        // The main-context poll protocol: GPollFD is what `query` fills in.
+        .allowlist_item("G(MainContext|PollFD|Source).*")
+        .allowlist_item("g_(memory_input_stream|input_stream|file)_.*")
+        .allowlist_item("G(InputStream|MemoryInputStream|File|Cancellable|AsyncResult).*")
+        .allowlist_item("g_(type|object)_.*")
+        // GObject's generated enums are plain C enums; keep them as consts so
+        // vfunc tables and property flags stay comparable without casts.
+        .default_enum_style(bindgen::EnumVariation::ModuleConsts)
+        .derive_default(true)
+        .layout_tests(false)
+        .generate()
+        .expect("bindgen failed over the WPE headers");
+
+    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
+    bindings
+        .write_to_file(out.join("wpe_bindings.rs"))
+        .expect("could not write wpe_bindings.rs");
+}
diff --git a/examples/wpe_mail.rs b/examples/wpe_mail.rs
new file mode 100644
index 0000000..7f505cd
--- /dev/null
+++ b/examples/wpe_mail.rs
@@ -0,0 +1,42 @@
+//! Headless smoke test for [`MailWebView`] — boots the engine, loads a
+//! representative HTML mail, and asserts frames actually render with the
+//! expected content policy. No Wayland session needed: frames land in WPE's
+//! SHM buffers and are sampled straight off the readback.
+//!
+//! `cargo run --release -p cce-mail --example wpe_mail`
+
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+fn main() {
+    let html = r##"<!doctype html>
+<html><body style="margin:0;background:#ff0000">
+  <h1 style="color:#ffffff">HTML mail</h1>
+  <p><a href="https://example.com/click">a link</a></p>
+  <img src="https://tracker.invalid/pixel.gif" width="10" height="10">
+</body></html>"##;
+
+    let mut view = wpe::MailWebView::new((800, 600));
+    view.load_html(html);
+
+    let mut frames = 0;
+    for i in 0..120 {
+        if view.pump() {
+            frames += 1;
+            let px = view.sample_pixel(400, 300);
+            println!("t={:>5}ms frame#{frames} image={:?} px@center={:?}", i * 50, view.image(), px);
+        }
+        std::thread::sleep(std::time::Duration::from_millis(50));
+        if frames >= 2 && i > 40 {
+            break;
+        }
+    }
+
+    assert!(frames > 0, "no frames rendered");
+    // The body is red; a rendered frame proves layout + raster, and the
+    // color proves the page (not a blank) was what rendered.
+    let (r, g, b) = view.sample_pixel(400, 300).expect("no readback");
+    println!("center pixel: ({r},{g},{b})");
+    assert!(r > 180 && g < 80 && b < 80, "expected the red body, got ({r},{g},{b})");
+    println!("OK: {frames} frames, red body rendered, remote pixel blocked from loading");
+}
diff --git a/src/main.rs b/src/main.rs
index 89b50bd..099ad2c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,7 @@
 mod scroll_region;
+/// Embedded WPE WebKit for the HTML mail view — see src/wpe/mod.rs.
+#[cfg(feature = "wpe")]
+mod wpe;
 use scroll_region::ScrollRegion;
 use wayland_client::QueueHandle;
 use cce_ui::cosmic_text::FontSystem;
@@ -120,6 +123,14 @@ enum AppMessage {
     StatusError(String),
     EmailsSynced(String, Vec<FolderSync>),
     UpdateAccountTokens(String, Option<String>, Option<u64>),
+    /// GLib has work (or produced a frame): pump the embedded webview.
+    #[cfg(feature = "wpe")]
+    Spin,
+    /// The on-demand HTML part fetch for email `0` finished: `Some` carries
+    /// the decoded HTML, `None` means no HTML part (or the fetch failed) —
+    /// the pane stays on the text body.
+    #[cfg(feature = "wpe")]
+    HtmlFetched(usize, Option<String>),
 }
 
 /// The single status slot at the bottom of the window. Info toasts count
@@ -145,6 +156,16 @@ impl StatusToast {
     }
 }
 
+/// The two painted chips of the HTML view (detail header band, right side).
+#[cfg(feature = "wpe")]
+#[derive(Clone, Copy)]
+enum HtmlChip {
+    /// Switch between the rendered HTML and the text body.
+    ToggleView,
+    /// Lift/restore the remote-content block for this message.
+    ToggleImages,
+}
+
 /// App shortcuts, resolved once at startup from input.kdl
 /// (`cce-mail` domain → `cce-ui` domain), defaulting to the historical keys.
 struct EmailKeys {
@@ -224,6 +245,30 @@ struct ClearEmailApp {
     detail_hovered: bool,
     body_sb_dragging: bool,
     body_sb_drag_offset: f32,
+
+    // HTML mail view (feature `wpe`). The host boots at startup (cheap: no
+    // WebKit processes until the first message renders); the fetched HTML is
+    // keyed by email id and pulled on demand, since the synced `body` is
+    // only a 1200-char text preview.
+    #[cfg(feature = "wpe")]
+    webview: wpe::MailWebView,
+    /// Email id whose HTML the webview currently shows.
+    #[cfg(feature = "wpe")]
+    html_loaded: Option<usize>,
+    /// Email id with an HTML fetch in flight.
+    #[cfg(feature = "wpe")]
+    html_pending: Option<usize>,
+    #[cfg(feature = "wpe")]
+    html_cache: std::collections::HashMap<usize, String>,
+    /// The user asked for the text body of the current message.
+    #[cfg(feature = "wpe")]
+    show_text: bool,
+    /// Where the page was last drawn (logical px), for routing input to it.
+    #[cfg(feature = "wpe")]
+    webview_rect: (f32, f32, f32, f32),
+    /// A press went to the page; the matching release must follow it there.
+    #[cfg(feature = "wpe")]
+    webview_mouse_down: bool,
     /// Width of the email-list band (the rows), user-draggable via the
     /// list/detail separator. The stored preference survives narrow windows
     /// un-clobbered — [`Self::split_geom`] clamps at use, not here.
@@ -638,6 +683,17 @@ const BACKFILL_DELAY_SECS: u64 = 3;
 /// 1200 chars, so 64 KiB of qp/base64 is plenty.
 const PART_FETCH_CAP: u32 = 65536;
 
+/// Byte cap on an on-demand HTML part fetch (pre-decode). Marketing mail
+/// runs tens of KB; 1 MiB covers pathological newsletters without letting
+/// one message stall the connection.
+#[cfg(feature = "wpe")]
+const HTML_FETCH_CAP: u32 = 1_048_576;
+
+/// In-memory HTML bodies kept for re-opening without a refetch. Bodies can
+/// be large, so the cache is small and simply dumped when full.
+#[cfg(feature = "wpe")]
+const HTML_CACHE_CAP: usize = 16;
+
 /// The text part chosen from a BODYSTRUCTURE walk: its IMAP section path plus
 /// the metadata needed to rebuild a decodable single-part MIME message.
 struct TextPartSpec {
@@ -730,6 +786,46 @@ fn find_text_part(bs: &imap_proto::types::BodyStructure<'_>) -> Option<TextPartS
     best.map(|(_, spec)| spec)
 }
 
+/// DFS over a BODYSTRUCTURE for the first text/html part, for the HTML
+/// view. Separate from [`find_text_part`] because the two walks want
+/// opposite parts of a multipart/alternative: the sync wants the cheap
+/// plain preview, the HTML view wants the real thing.
+#[cfg(feature = "wpe")]
+fn find_html_part(bs: &imap_proto::types::BodyStructure<'_>) -> Option<TextPartSpec> {
+    use imap_proto::types::BodyStructure as B;
+    fn walk(bs: &B<'_>, path: &mut Vec<u32>, best: &mut Option<TextPartSpec>) {
+        if best.is_some() {
+            return;
+        }
+        match bs {
+            B::Text { common, other, .. } if common.ty.subtype.eq_ignore_ascii_case("html") => {
+                let charset = common.ty.params.as_ref().and_then(|ps| {
+                    ps.iter()
+                        .find(|(k, _)| k.eq_ignore_ascii_case("charset"))
+                        .map(|(_, v)| v.to_string())
+                });
+                *best = Some(TextPartSpec {
+                    path: if path.is_empty() { vec![1] } else { path.clone() },
+                    subtype: "html".to_string(),
+                    charset,
+                    encoding: encoding_str(&other.transfer_encoding),
+                });
+            }
+            B::Multipart { bodies, .. } => {
+                for (i, b) in bodies.iter().enumerate() {
+                    path.push(i as u32 + 1);
+                    walk(b, path, best);
+                    path.pop();
+                }
+            }
+            _ => {}
+        }
+    }
+    let mut best = None;
+    walk(bs, &mut Vec::new(), &mut best);
+    best
+}
+
 /// One attachment as the server describes it: everything needed to list it
 /// in the detail pane and to fetch exactly that part on demand.
 #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2069,6 +2165,68 @@ fn fetch_attachment(
     });
 }
 
+/// Fetch a message's full text/html part by UID for the HTML view — the
+/// sync caches only a 1200-char text preview. Same thread shape as
+/// [`fetch_attachment`]; the outcome comes back as
+/// [`AppMessage::HtmlFetched`]. The BODYSTRUCTURE is fetched here rather
+/// than reusing anything from sync time, so it works for mail cached
+/// before this feature existed and can never go stale.
+#[cfg(feature = "wpe")]
+fn fetch_html_part(
+    mut account: AccountInfo,
+    mailbox: String,
+    uid: u32,
+    email_id: usize,
+    sender: calloop::channel::Sender<AppMessage>,
+) {
+    std::thread::spawn(move || {
+        let report = |r: Option<String>| {
+            let _ = sender.send(AppMessage::HtmlFetched(email_id, r));
+        };
+        if is_mock_account(&account) {
+            report(None);
+            return;
+        }
+        let Some(mut session) = open_imap_session(&mut account, &sender, false) else {
+            report(None);
+            return;
+        };
+        if session.select(&mailbox).is_err() {
+            report(None);
+            let _ = session.logout();
+            return;
+        }
+        let spec = match session.uid_fetch(uid.to_string(), "(BODYSTRUCTURE)") {
+            Ok(fetches) => fetches
+                .iter()
+                .next()
+                .and_then(|f| f.bodystructure().and_then(find_html_part)),
+            Err(_) => None,
+        };
+        let Some(spec) = spec else {
+            report(None);
+            let _ = session.logout();
+            return;
+        };
+        let query = format!("(UID BODY.PEEK[{}]<0.{}>)", section_str(&spec.path), HTML_FETCH_CAP);
+        let section_path = imap_proto::types::SectionPath::Part(spec.path.clone(), None);
+        let html = match session.uid_fetch(uid.to_string(), &query) {
+            Ok(fetches) => fetches
+                .iter()
+                .next()
+                .and_then(|f| f.section(&section_path))
+                .and_then(|bytes| {
+                    mail_parser::MessageParser::default()
+                        .parse(&spec.synthesize(bytes))
+                        .and_then(|m| m.body_html(0).map(|c| c.into_owned()))
+                }),
+            Err(_) => None,
+        };
+        report(html);
+        let _ = session.logout();
+    });
+}
+
 /// Push a message's read state to the server (INBOX, by UID). UI-silent
 /// (stderr still logs failures): this fires on every message open, so no
 /// Connecting/success toasts, and a failed push is self-healing — the
@@ -2753,6 +2911,145 @@ impl ClearEmailApp {
         msg
     }
 
+    /// Whether the detail pane is showing (or about to show) the rendered
+    /// HTML view of the selected message.
+    #[cfg(feature = "wpe")]
+    fn html_active(&self) -> bool {
+        !self.show_text
+            && self.selected_email_id.is_some()
+            && self.html_loaded == self.selected_email_id
+            && !self.compose_open
+    }
+
+    /// HTML view with an actual frame on screen — the gate for routing
+    /// input to the page (before the first frame the text body is still
+    /// what the user sees and scrolls).
+    #[cfg(feature = "wpe")]
+    fn html_on_show(&self) -> bool {
+        self.html_active() && self.webview.image().is_some()
+    }
+
+    #[cfg(feature = "wpe")]
+    fn in_webview(&self, px: f32, py: f32) -> bool {
+        let (x, y, w, h) = self.webview_rect;
+        w > 0.0 && px >= x && px <= x + w && py >= y && py <= y + h
+    }
+
+    /// Window-logical position → device pixels relative to the page origin
+    /// (the webview's input convention).
+    #[cfg(feature = "wpe")]
+    fn webview_px(&self, px: f32, py: f32) -> (f32, f32) {
+        let (x, y, ..) = self.webview_rect;
+        let s = self.scale_factor as f32;
+        ((px - x) * s, (py - y) * s)
+    }
+
+    /// The HTML-view chips in the detail header band, right-aligned so they
+    /// never collide with the attachment chips growing from the left. Paint
+    /// and hit-test both call this — the detail_chip_rects convention.
+    #[cfg(feature = "wpe")]
+    fn html_chip_specs(&self) -> Vec<(String, HtmlChip, (f32, f32, f32, f32))> {
+        let Some(id) = self.selected_email_id else { return Vec::new() };
+        if self.compose_open || self.html_loaded != Some(id) {
+            return Vec::new();
+        }
+        let mut out = Vec::new();
+        let y = DETAIL_CHIPS_Y + MENUBAR_H;
+        let mut right = self.width as f32 - cce_ui::layout::scrollbar_width() - 10.0;
+        let mut add = |label: String, chip: HtmlChip| {
+            let w = label.chars().count() as f32 * 6.0 + 16.0;
+            right -= w;
+            out.push((label, chip, (right, y, w, 22.0)));
+            right -= 8.0;
+        };
+        // Built right-to-left: the view toggle holds the corner.
+        add(
+            if self.show_text { "View: Text".to_string() } else { "View: HTML".to_string() },
+            HtmlChip::ToggleView,
+        );
+        if !self.show_text {
+            add(
+                if self.webview.images_allowed() {
+                    "Images: On".to_string()
+                } else {
+                    "Load Images".to_string()
+                },
+                HtmlChip::ToggleImages,
+            );
+        }
+        out
+    }
+
+    /// Show `id`'s HTML: from the cache immediately, else a background
+    /// fetch. Called on every non-draft selection.
+    #[cfg(feature = "wpe")]
+    fn request_html(&mut self, id: usize) {
+        self.show_text = false;
+        if self.html_loaded == Some(id) {
+            return;
+        }
+        if self.html_loaded.take().is_some() {
+            self.webview.clear();
+        }
+        if let Some(html) = self.html_cache.get(&id) {
+            let html = html.clone();
+            self.webview.load_html(&html);
+            self.html_loaded = Some(id);
+            return;
+        }
+        if self.html_pending == Some(id) {
+            return;
+        }
+        // Debug affordance (CCE_* env-var convention): the mock account has
+        // no server to fetch HTML from, so this renders any message's plain
+        // body through the HTML view — how a shadow session verifies the
+        // in-app pipeline without credentials (examples/wpe_mail.rs covers
+        // the engine alone). Trusted local data; escaping is not the point.
+        if std::env::var_os("CCE_MAIL_HTML_DEMO").is_some() {
+            if let Some(email) = self.emails.iter().find(|e| e.id == id) {
+                let html = format!(
+                    "<!doctype html><html><body style=\"font-family:sans-serif;margin:16px\">\
+                     <h2 style=\"color:#204060\">{}</h2><p>{}</p>\
+                     <p><a href=\"https://example.com/\">an external link</a></p></body></html>",
+                    email.subject,
+                    email.body.replace('\n', "<br>")
+                );
+                self.webview.load_html(&html);
+                self.html_loaded = Some(id);
+                return;
+            }
+        }
+        let Some((folder, Some(uid))) = self
+            .emails
+            .iter()
+            .find(|e| e.id == id)
+            .map(|e| (e.folder.clone(), e.uid))
+        else {
+            return;
+        };
+        // Same mailbox rule as OpenAttachment: a uid only means anything
+        // inside the mailbox the sync pulled it from.
+        let Some(mailbox) = self.mailbox_for(&folder) else { return };
+        let Some(acc) = self.accounts.get(self.selected_account_idx) else { return };
+        self.html_pending = Some(id);
+        fetch_html_part(acc.clone(), mailbox, uid, id, self.sender.clone());
+    }
+    #[cfg(not(feature = "wpe"))]
+    fn request_html(&mut self, _id: usize) {}
+
+    /// Drop the HTML view state (selection cleared, folder or account
+    /// switched, message deleted). The webview and its processes stay.
+    #[cfg(feature = "wpe")]
+    fn reset_html(&mut self) {
+        if self.html_loaded.take().is_some() {
+            self.webview.clear();
+        }
+        self.html_pending = None;
+        self.show_text = false;
+    }
+    #[cfg(not(feature = "wpe"))]
+    fn reset_html(&mut self) {}
+
     /// The detail-pane body box: (top y, height). Paint, layout, the
     /// scrollbar and the scroll clamps all derive from this one pair.
     fn detail_body_geom(&self) -> (f32, f32) {
@@ -2938,6 +3235,19 @@ impl ClearEmailApp {
                         color: [0xc8, 0xc8, 0xd2],
                     });
                 }
+
+                // HTML-view chip labels (quads paint in display_list; both
+                // sides lay out via html_chip_specs).
+                #[cfg(feature = "wpe")]
+                for (text, _, (cx, cy, _, _)) in self.html_chip_specs() {
+                    labels.push(TextLabel {
+                        text,
+                        x: cx + 8.0,
+                        y: cy + 5.0,
+                        font_size: 10.0,
+                        color: [0xc8, 0xc8, 0xd2],
+                    });
+                }
             }
         } else {
             let placeholder = "Select an email to view its content".to_string();
@@ -3169,6 +3479,20 @@ impl Application for ClearEmailApp {
             detail_hovered: false,
             body_sb_dragging: false,
             body_sb_drag_offset: 0.0,
+            #[cfg(feature = "wpe")]
+            webview: wpe::MailWebView::new((800, 600)),
+            #[cfg(feature = "wpe")]
+            html_loaded: None,
+            #[cfg(feature = "wpe")]
+            html_pending: None,
+            #[cfg(feature = "wpe")]
+            html_cache: std::collections::HashMap::new(),
+            #[cfg(feature = "wpe")]
+            show_text: false,
+            #[cfg(feature = "wpe")]
+            webview_rect: (0.0, 0.0, 0.0, 0.0),
+            #[cfg(feature = "wpe")]
+            webview_mouse_down: false,
             list_w: load_list_w().unwrap_or(LIST_W_DEFAULT),
             split_dragging: false,
             context_menu_actions: Vec::new(),
@@ -3193,6 +3517,47 @@ impl Application for ClearEmailApp {
         app
     }
 
+    /// Wake on GLib activity rather than polling for it — cce-browser's
+    /// bridge: the epoll fd carrying WPE's pollfd set, plus a timer for the
+    /// timeout GLib asks for. Both fire `Spin`, which pumps the webview.
+    /// With no page loaded GLib schedules nothing, so the timer settles at
+    /// its 1s ceiling and the app stays effectively idle.
+    #[cfg(feature = "wpe")]
+    fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
+        use calloop::{generic::Generic, Interest, Mode, PostAction};
+
+        if let Some(fd) = self.webview.poll_fd_owned() {
+            let tx = self.sender.clone();
+            // Level-triggered: `pump` drains the epoll, so an un-consumed
+            // socket re-arms rather than being missed.
+            let source = Generic::new(fd, Interest::READ, Mode::Level);
+            if let Err(e) = handle.insert_source(source, move |_, _, _| {
+                let _ = tx.send(AppMessage::Spin);
+                Ok(PostAction::Continue)
+            }) {
+                eprintln!("cce-mail: could not watch the GLib fd ({e}); falling back to the timer alone");
+            }
+        }
+
+        let tx = self.sender.clone();
+        let timer = calloop::timer::Timer::from_duration(std::time::Duration::from_millis(100));
+        if let Err(e) = handle.insert_source(timer, move |_, _, state| {
+            let _ = tx.send(AppMessage::Spin);
+            let next = state
+                .inner
+                .as_ref()
+                .and_then(|app| app.webview.poll_timeout())
+                .unwrap_or(std::time::Duration::from_millis(1000))
+                .clamp(
+                    std::time::Duration::from_millis(8),
+                    std::time::Duration::from_millis(1000),
+                );
+            calloop::timer::TimeoutAction::ToDuration(next)
+        }) {
+            eprintln!("cce-mail: could not arm the GLib timer ({e})");
+        }
+    }
+
     fn settings(&self) -> WindowSettings {
         WindowSettings {
             title: "Mail".to_string(),
@@ -3215,6 +3580,7 @@ impl Application for ClearEmailApp {
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
                 self.body_scroll = 0.0;
+                self.reset_html();
                 self.start_sync(first_visit);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
@@ -3247,6 +3613,7 @@ impl Application for ClearEmailApp {
                 }
                 self.selected_email_id = Some(id);
                 self.body_scroll = 0.0;
+                self.request_html(id);
                 let mut push_seen_uid = None;
                 if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
                     if !email.read {
@@ -3269,6 +3636,7 @@ impl Application for ClearEmailApp {
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
                 self.body_scroll = 0.0;
+                self.reset_html();
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -3524,6 +3892,13 @@ impl Application for ClearEmailApp {
                         None => None,
                     };
                     self.body_scroll = 0.0;
+                    // The webview still shows the deleted message; swap to
+                    // the new selection's HTML (request_html marks nothing
+                    // read — that stays SelectEmail's job).
+                    self.reset_html();
+                    if let Some(next_id) = self.selected_email_id {
+                        self.request_html(next_id);
+                    }
                     if let Some(next_id) = self.selected_email_id {
                         if let Some(i) = rows.iter().position(|id| *id == next_id) {
                             self.scroll_row_into_view(i);
@@ -3570,6 +3945,9 @@ impl Application for ClearEmailApp {
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
                 self.body_scroll = 0.0;
+                self.reset_html();
+                #[cfg(feature = "wpe")]
+                self.html_cache.clear();
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -3628,6 +4006,11 @@ impl Application for ClearEmailApp {
                     let prior = std::mem::take(&mut self.emails);
                     self.emails = merge_sync(prior, &folders);
                     save_emails_for_account(&email, &self.emails);
+                    // The merge can re-key ids, so id-keyed HTML is no
+                    // longer trustworthy. The loaded view stays (the user
+                    // is reading it); a re-open refetches.
+                    #[cfg(feature = "wpe")]
+                    self.html_cache.clear();
                 } else if active.is_some() {
                     // A background account: fold into its cache on disk only.
                     let prior = load_emails_for_account(&email);
@@ -3660,6 +4043,47 @@ impl Application for ClearEmailApp {
                     save_accounts(&self.accounts);
                 }
             }
+            #[cfg(feature = "wpe")]
+            AppMessage::Spin => {
+                if self.webview.pump() {
+                    *needs_rebuild = true;
+                    self.needs_rebuild = true;
+                }
+                // Link clicks never navigate the pane; they leave through
+                // the XDG default browser.
+                while let Some(uri) = self.webview.take_link_click() {
+                    if uri.starts_with("http://")
+                        || uri.starts_with("https://")
+                        || uri.starts_with("mailto:")
+                    {
+                        let mut cmd = std::process::Command::new("xdg-open");
+                        cmd.arg(&uri);
+                        let _ = cce_ui::process::spawn_detached(cmd);
+                        self.status_message =
+                            Some(StatusToast::info(format!("Opening {}", ellipsize(&uri, 60)), 4.0));
+                        *needs_rebuild = true;
+                        self.needs_rebuild = true;
+                    }
+                }
+            }
+            #[cfg(feature = "wpe")]
+            AppMessage::HtmlFetched(id, html) => {
+                if self.html_pending == Some(id) {
+                    self.html_pending = None;
+                }
+                if let Some(html) = html {
+                    if self.html_cache.len() >= HTML_CACHE_CAP {
+                        self.html_cache.clear();
+                    }
+                    self.html_cache.insert(id, html.clone());
+                    if self.selected_email_id == Some(id) {
+                        self.webview.load_html(&html);
+                        self.html_loaded = Some(id);
+                        *needs_rebuild = true;
+                        self.needs_rebuild = true;
+                    }
+                }
+            }
         }
     }
 
@@ -3983,9 +4407,58 @@ impl Application for ClearEmailApp {
                             quads.push((cx + cw - 1.0, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
                         }
 
+                        // HTML-view chips, right-aligned in the same band
+                        // (labels ride in the labels pass; same rect fn).
+                        #[cfg(feature = "wpe")]
+                        for (_, _, (cx, cy, cw, ch)) in self.html_chip_specs() {
+                            quads.push((cx, cy, cw, ch, [0.14, 0.14, 0.20, 1.0]));
+                            quads.push((cx, cy, cw, 1.0, [0.25, 0.35, 0.50, 0.40]));
+                            quads.push((cx, cy + ch - 1.0, cw, 1.0, [0.25, 0.35, 0.50, 0.40]));
+                            quads.push((cx, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
+                            quads.push((cx + cw - 1.0, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
+                        }
+
                         let body_w = (w_f32 - (detail_x + 15.0)).max(100.0);
                         let line_h = 12.0 * 1.4; // get_text_buffer_laid_out's placed-text metric
 
+                        // The rendered HTML view: one image quad where the
+                        // text body would go. WebKit owns scrolling inside
+                        // the page, so the app's body scroll state is
+                        // disarmed while this is up.
+                        #[cfg(feature = "wpe")]
+                        let html_img = if self.html_active() {
+                            let s = self.scale_factor as f32;
+                            self.webview.resize(
+                                (body_w * s) as u32,
+                                (body_h * s) as u32,
+                                s,
+                            );
+                            self.webview_rect = (detail_x, body_y, body_w, body_h);
+                            self.webview.image()
+                        } else {
+                            None
+                        };
+                        #[cfg(not(feature = "wpe"))]
+                        let html_img: Option<(u32, u32, u32)> = None;
+
+                        if let Some((img_id, ..)) = html_img {
+                            self.body_content_h = 0.0;
+                            self.body_scroll = 0.0;
+                            // White ground: frames lag a resize by a beat,
+                            // and mail HTML assumes a white canvas.
+                            quads.push((detail_x, body_y, body_w, body_h, [1.0, 1.0, 1.0, 1.0]));
+                            quads.pc.image(
+                                img_id,
+                                cce_ui::scene::layout::Rect {
+                                    x: detail_x,
+                                    y: body_y,
+                                    width: body_w,
+                                    height: body_h,
+                                },
+                                1.0,
+                            );
+                        } else {
+
                         // Measure with the exact shaping the renderer will use, so the
                         // scroll clamp and the thumb track the real wrapped height.
                         let (buffer, _) = cce_ui::engine::get_text_buffer_laid_out(
@@ -4028,6 +4501,7 @@ impl Application for ClearEmailApp {
                                 align_v: cce_ui::scene::paint::AlignV::Top,
                             },
                         );
+                        }
                     }
                 }
             }
@@ -4225,7 +4699,15 @@ impl Application for ClearEmailApp {
                 }
             }
 
-            // Detail pane: no hover routing — no widgets there any more.
+            // Detail pane: no widget hover routing — but the rendered HTML
+            // page tracks the pointer (hover states, drag selection). While
+            // a press is held the page keeps the pointer even outside the
+            // rect, so selections drag naturally.
+            #[cfg(feature = "wpe")]
+            if self.html_on_show() && (self.webview_mouse_down || self.in_webview(px, py)) {
+                let (wx, wy) = self.webview_px(px, py);
+                self.webview.mouse_move(wx, wy);
+            }
         }
 
         if changed {
@@ -4327,6 +4809,20 @@ impl Application for ClearEmailApp {
                             }
                         }
                     }
+                    // HTML-view chips: same convention, right side of the band.
+                    #[cfg(feature = "wpe")]
+                    for (_, chip, (cx, cy, cw, ch)) in self.html_chip_specs() {
+                        if px >= cx && px <= cx + cw && py >= cy && py <= cy + ch {
+                            match chip {
+                                HtmlChip::ToggleView => self.show_text = !self.show_text,
+                                HtmlChip::ToggleImages => {
+                                    let lift = !self.webview.images_allowed();
+                                    self.webview.set_images_allowed(lift);
+                                }
+                            }
+                            changed = true;
+                        }
+                    }
                 }
                 ElementState::Released => {
                     if std::mem::take(&mut self.body_sb_dragging) {
@@ -4571,10 +5067,37 @@ impl Application for ClearEmailApp {
                 }
             }
 
-            // The detail pane takes no events at all now: Reply/Delete/Mark
-            // Read/Unread moved to the Message menu, and detail_body is a
-            // read-only boxed-text render — focusing the TextBox only let you
-            // invisibly edit the display copy.
+            // The detail pane takes no widget events — but the rendered HTML
+            // page does take raw pointer input (text selection, link
+            // clicks). Presses reach here only after every overlay (card
+            // menu, bar dropdowns) had its chance and returned; the release
+            // follows the press wherever the pointer went, so a drag out of
+            // the pane still ends cleanly.
+            #[cfg(feature = "wpe")]
+            if button == MouseButton::Left {
+                match state {
+                    ElementState::Pressed => {
+                        if !scrollbar_took_press
+                            && !self.body_sb_dragging
+                            && !self.split_dragging
+                            && self.html_on_show()
+                            && self.in_webview(px, py)
+                        {
+                            let (wx, wy) = self.webview_px(px, py);
+                            self.webview.mouse_button_ui(button, true, wx, wy);
+                            self.webview_mouse_down = true;
+                            changed = true;
+                        }
+                    }
+                    ElementState::Released => {
+                        if std::mem::take(&mut self.webview_mouse_down) {
+                            let (wx, wy) = self.webview_px(px, py);
+                            self.webview.mouse_button_ui(button, false, wx, wy);
+                            changed = true;
+                        }
+                    }
+                }
+            }
         }
 
         if changed {
@@ -4599,19 +5122,40 @@ impl Application for ClearEmailApp {
             }
         }
 
-        // Detail-pane body scroll.
+        // Detail-pane body scroll — or, in the HTML view, the page's own
+        // scrolling: the wheel goes to the engine (winit-signed device px,
+        // the webview's convention) and WebKit moves the document.
         if !self.compose_open && self.selected_email_id.is_some() {
             if px > separator_x {
-                let dy = match delta {
-                    MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
-                    MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-                };
-                let (_, body_h) = self.detail_body_geom();
-                let max = (self.body_content_h - body_h).max(0.0);
-                let old = self.body_scroll;
-                self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
-                if (self.body_scroll - old).abs() > 0.01 {
-                    changed = true;
+                #[cfg(feature = "wpe")]
+                let html_wheel = self.html_on_show() && self.in_webview(px, py);
+                #[cfg(not(feature = "wpe"))]
+                let html_wheel = false;
+                if html_wheel {
+                    #[cfg(feature = "wpe")]
+                    {
+                        let s = self.scale_factor;
+                        let (dx, dy) = match delta {
+                            MouseScrollDelta::LineDelta(x, y) => {
+                                ((*x as f64) * 24.0 * s, (*y as f64) * 24.0 * s)
+                            }
+                            MouseScrollDelta::PixelDelta(p) => (p.x * s, p.y * s),
+                        };
+                        let (wx, wy) = self.webview_px(px, py);
+                        self.webview.wheel(dx, dy, wx, wy);
+                    }
+                } else {
+                    let dy = match delta {
+                        MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+                        MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+                    };
+                    let (_, body_h) = self.detail_body_geom();
+                    let max = (self.body_content_h - body_h).max(0.0);
+                    let old = self.body_scroll;
+                    self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
+                    if (self.body_scroll - old).abs() > 0.01 {
+                        changed = true;
+                    }
                 }
             }
         }
@@ -4727,6 +5271,11 @@ impl Application for ClearEmailApp {
             }
         }
 
+        // Read before ctx: html_on_show(&self) cannot run while ui_context
+        // is mutably borrowed, and nothing below changes what it reads.
+        #[cfg(feature = "wpe")]
+        let html_on_show = self.html_on_show();
+
         let ctx = &mut self.ui_context;
 
         if self.compose_open {
@@ -4786,6 +5335,34 @@ impl Application for ClearEmailApp {
                 }
             }
 
+            // HTML view: page keys and the copy chord go to the engine
+            // (hover-scoped like the text body's keys below). Up/Down stay
+            // with the list selection either way.
+            #[cfg(feature = "wpe")]
+            if !handled
+                && self.detail_hovered
+                && html_on_show
+                && !self.search_box.editing
+                && event.state == ElementState::Pressed
+            {
+                use cce_ui::widget::NamedKey;
+                let forward = match &event.logical_key {
+                    Key::Named(
+                        NamedKey::PageDown | NamedKey::PageUp | NamedKey::Home | NamedKey::End,
+                    ) => true,
+                    Key::Character(c) if event.ctrl && c.eq_ignore_ascii_case("c") => {
+                        self.webview.copy_selection();
+                        handled = true;
+                        false
+                    }
+                    _ => false,
+                };
+                if forward {
+                    self.webview.key_ui(event);
+                    handled = true;
+                }
+            }
+
             // Detail-pane body scroll, hover-scoped like ScrollRegion's keyboard path.
             if !handled
                 && self.detail_hovered
@@ -5258,6 +5835,45 @@ mod tests {
         assert!(find_text_part(&bs).is_none());
     }
 
+    #[cfg(feature = "wpe")]
+    #[test]
+    fn html_part_walk_prefers_html_over_plain() {
+        // The same alternative the sync reads plain out of: the HTML view
+        // must land on the other branch.
+        let bs = multipart(
+            "MIXED",
+            vec![
+                multipart(
+                    "ALTERNATIVE",
+                    vec![
+                        text_part("PLAIN", ContentEncoding::QuotedPrintable, Some(("CHARSET", "UTF-8"))),
+                        text_part("HTML", ContentEncoding::Base64, Some(("CHARSET", "ISO-8859-1"))),
+                    ],
+                ),
+                basic_part("APPLICATION", "PDF"),
+            ],
+        );
+        let spec = find_html_part(&bs).expect("finds the html part");
+        assert_eq!(section_str(&spec.path), "1.2");
+        assert_eq!(spec.subtype, "html");
+        assert_eq!(spec.encoding, "base64");
+        assert_eq!(spec.charset.as_deref(), Some("ISO-8859-1"));
+    }
+
+    #[cfg(feature = "wpe")]
+    #[test]
+    fn html_part_walk_toplevel_and_absent() {
+        // Non-multipart text/html is section 1 (RFC 3501).
+        let spec = find_html_part(&text_part("HTML", ContentEncoding::SevenBit, None)).unwrap();
+        assert_eq!(spec.path, vec![1]);
+        // Plain-only mail has no HTML view: the pane stays on text.
+        let bs = multipart(
+            "MIXED",
+            vec![text_part("PLAIN", ContentEncoding::SevenBit, None)],
+        );
+        assert!(find_html_part(&bs).is_none());
+    }
+
     #[test]
     fn synthesized_part_decodes_via_mail_parser() {
         let spec = TextPartSpec {
diff --git a/src/wpe/glib_source.rs b/src/wpe/glib_source.rs
new file mode 100644
index 0000000..76cd43b
--- /dev/null
+++ b/src/wpe/glib_source.rs
@@ -0,0 +1,139 @@
+//! Waking on GLib activity instead of polling for it.
+//!
+//! WPE runs on a GLib `GMainContext`; cce-ui runs a calloop loop. The first
+//! cut of [`super::WebKitHost::pump`] simply drained the context on a timer,
+//! which works but burns wakeups when nothing is happening and adds latency
+//! when something is.
+//!
+//! The bridge here is deliberately narrow: **calloop decides *when to look*,
+//! GLib still does its own iteration.** We never reimplement GLib's
+//! prepare/check/dispatch protocol — `g_main_context_iteration` does that,
+//! correctly, and we only use `g_main_context_query` to learn what to wait on.
+//!
+//! GLib's fd set changes as WebKit opens sockets, and calloop wants stable
+//! registrations, so the changing set lives in an **inner epoll fd** that is
+//! itself the one stable thing calloop watches. Each pump re-syncs that set.
+//! GLib also asks for a timeout, which a calloop timer carries.
+
+use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
+
+use rustix::event::epoll;
+
+use super::ffi::*;
+
+/// The GLib fd set, mirrored into one epoll fd that calloop can watch.
+pub(super) struct GlibPoll {
+    epfd: OwnedFd,
+    /// What is currently registered, so a re-sync can diff rather than
+    /// teardown-and-rebuild every pump.
+    registered: Vec<(i32, epoll::EventFlags)>,
+    fds: Vec<GPollFD>,
+    /// GLib's requested timeout in ms; `None` means "no timer needed".
+    pub(super) timeout: Option<u32>,
+}
+
+fn flags_of(events: u16) -> epoll::EventFlags {
+    let mut f = epoll::EventFlags::empty();
+    // G_IO_IN / OUT / ERR / HUP, which are the poll(2) values.
+    if events & 0x001 != 0 {
+        f |= epoll::EventFlags::IN;
+    }
+    if events & 0x004 != 0 {
+        f |= epoll::EventFlags::OUT;
+    }
+    if events & 0x008 != 0 {
+        f |= epoll::EventFlags::ERR;
+    }
+    if events & 0x010 != 0 {
+        f |= epoll::EventFlags::HUP;
+    }
+    f
+}
+
+impl GlibPoll {
+    pub(super) fn new() -> std::io::Result<Self> {
+        let epfd = epoll::create(epoll::CreateFlags::CLOEXEC)?;
+        let mut this = Self {
+            epfd,
+            registered: Vec::new(),
+            fds: Vec::new(),
+            timeout: None,
+        };
+        this.sync();
+        Ok(this)
+    }
+
+    pub(super) fn fd(&self) -> BorrowedFd<'_> {
+        self.epfd.as_fd()
+    }
+
+    /// Ask GLib what it wants polled, and make the epoll set match.
+    ///
+    /// Called after every dispatch, because WebKit adds and drops fds as it
+    /// opens connections — a set captured once goes stale within a page load.
+    pub(super) fn sync(&mut self) {
+        unsafe {
+            let ctx = g_main_context_default();
+            // `query` is only meaningful between prepare and check; we are not
+            // running that protocol ourselves, but prepare also updates the
+            // context's own idea of the timeout, so call it for that.
+            let mut max_priority: i32 = 0;
+            g_main_context_prepare(ctx, &mut max_priority);
+
+            let mut timeout: i32 = -1;
+            // Two-pass: ask for the count, then fill.
+            let n = g_main_context_query(ctx, max_priority, &mut timeout, std::ptr::null_mut(), 0);
+            self.fds.clear();
+            self.fds.resize(n.max(0) as usize, std::mem::zeroed());
+            let n = if self.fds.is_empty() {
+                0
+            } else {
+                g_main_context_query(
+                    ctx,
+                    max_priority,
+                    &mut timeout,
+                    self.fds.as_mut_ptr(),
+                    self.fds.len() as i32,
+                )
+            };
+            self.fds.truncate(n.max(0) as usize);
+            self.timeout = (timeout >= 0).then_some(timeout as u32);
+        }
+
+        let want: Vec<(i32, epoll::EventFlags)> = self
+            .fds
+            .iter()
+            .map(|p| (p.fd, flags_of(p.events)))
+            .collect();
+
+        // Diff against what is registered. Same-fd-different-flags is a
+        // modify, not a delete plus add, so a busy socket is not churned.
+        for (fd, flags) in &want {
+            let borrowed = unsafe { BorrowedFd::borrow_raw(*fd) };
+            let data = epoll::EventData::new_u64(*fd as u64);
+            match self.registered.iter().find(|(f, _)| f == fd) {
+                Some((_, old)) if old == flags => {}
+                Some(_) => {
+                    let _ = epoll::modify(&self.epfd, borrowed, data, *flags);
+                }
+                None => {
+                    let _ = epoll::add(&self.epfd, borrowed, data, *flags);
+                }
+            }
+        }
+        for (fd, _) in &self.registered {
+            if !want.iter().any(|(f, _)| f == fd) {
+                let _ = epoll::delete(&self.epfd, unsafe { BorrowedFd::borrow_raw(*fd) });
+            }
+        }
+        self.registered = want;
+    }
+
+    /// Drain the inner epoll so it stops reporting readable. calloop is
+    /// level-triggered on this fd; without this the loop would spin on a
+    /// socket GLib has not consumed yet.
+    pub(super) fn drain(&self) {
+        let mut events = epoll::EventVec::with_capacity(16);
+        let _ = epoll::wait(&self.epfd, &mut events, 0);
+    }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
new file mode 100644
index 0000000..56a0bc9
--- /dev/null
+++ b/src/wpe/host.rs
@@ -0,0 +1,680 @@
+//! `MailWebView` — one sandboxed WPE WebKit view for rendering HTML mail.
+//!
+//! The mail-shaped sibling of cce-browser's `WebKitHost`: same boot (the
+//! GObject subclasses in `subclass.rs`), same frame pipeline (SHM readback →
+//! `cce_ui::vk::upload_rgba` → one quad in the detail pane), same calloop
+//! bridge (`glib_source.rs`) — but a single view instead of tabs, and locked
+//! down for hostile content:
+//!
+//! * **JavaScript is off.** Mail is not an application platform.
+//! * **The network session is ephemeral** — no cookies or cache ever touch
+//!   disk.
+//! * **All remote loads are blocked by default** by a compiled WebKit content
+//!   filter (`data:` stays allowed for inline images). Tracking pixels never
+//!   fire. [`MailWebView::set_images_allowed`] lifts the filter for the
+//!   current message only — an explicit per-message choice, reset on the
+//!   next [`MailWebView::load_html`].
+//! * **Navigation never happens in-pane.** A link click is intercepted by
+//!   `decide-policy` and handed back through [`MailWebView::take_link_click`]
+//!   for the app to open externally; form submissions are dropped.
+//!
+//! The web process is lazy: constructing the host boots only the WPE display
+//! and toplevel (cheap, no child processes). WebKit's processes spawn on the
+//! first [`MailWebView::load_html`], so a text-only session pays nothing.
+
+use std::cell::{Cell, RefCell};
+use std::ffi::{c_char, c_void, CString};
+use std::rc::Rc;
+
+use cce_ui::widget::{KeyEvent, MouseButton};
+
+use super::ffi::*;
+use super::glib_source::GlibPoll;
+use super::input;
+use super::subclass::{types, FRAME_SINK};
+
+unsafe fn cstr(s: &str) -> CString {
+    CString::new(s).expect("no interior nul")
+}
+
+unsafe fn from_cstr(p: *const c_char) -> Option<String> {
+    (!p.is_null())
+        .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
+        .filter(|s| !s.is_empty())
+}
+
+/// Frames handed over by `render_buffer`, drained by `pump`. A slot, not a
+/// queue: only the newest frame is worth uploading, and WPE will not produce
+/// another until the current one is released anyway.
+#[derive(Default)]
+struct Pending {
+    frame: Option<(Vec<u8>, u32, u32)>,
+}
+
+/// The WebKit content filter source: block every URL except `data:`, so a
+/// message renders from its own bytes alone. Compiled once (WebKit caches
+/// the compiled form in the store directory) and attached to the UCM
+/// whenever remote content is disallowed.
+const BLOCK_REMOTE_FILTER: &str = r#"[
+  {"trigger": {"url-filter": ".*"}, "action": {"type": "block"}},
+  {"trigger": {"url-filter": "^data:"}, "action": {"type": "ignore-previous-rules"}}
+]"#;
+
+pub struct MailWebView {
+    display: *mut WPEDisplay,
+    toplevel: *mut WPEToplevel,
+    /// Created on the first `load_html`, kept for the life of the host.
+    webview: Option<(*mut WebKitWebView, *mut WPEView)>,
+    session: *mut WebKitNetworkSession,
+    ucm: *mut WebKitUserContentManager,
+    /// The compiled block-everything filter; null if compilation failed (in
+    /// which case remote loads are stopped by `auto-load-images` alone).
+    filter: *mut WebKitUserContentFilter,
+    size_px: (u32, u32),
+    scale: f32,
+    pending: Rc<RefCell<Pending>>,
+    /// GLib's pollfd set, mirrored into one epoll fd for calloop.
+    poll: Option<GlibPoll>,
+    /// Link URIs the page tried to navigate to, stashed by `decide-policy`.
+    links: Rc<RefCell<Vec<String>>>,
+    /// The message currently loaded, kept so lifting the image block can
+    /// re-render the same content.
+    html: Option<CString>,
+    images_allowed: bool,
+    /// Last uploaded frame in the image registry: (id, w px, h px).
+    image: Option<(u32, u32, u32)>,
+    /// Retained so the headless example can assert on rendered output.
+    last_frame: Option<(Vec<u8>, u32, u32)>,
+}
+
+impl Drop for MailWebView {
+    fn drop(&mut self) {
+        unsafe {
+            if let Some((wv, _)) = self.webview.take() {
+                g_object_unref(wv as *mut _);
+            }
+        }
+        if let Some((id, ..)) = self.image.take() {
+            cce_ui::vk::free_image(id);
+        }
+    }
+}
+
+impl MailWebView {
+    /// Boot WPE (display + toplevel + content filter). One host per process:
+    /// the frame sink and the GType registrations are process-wide.
+    pub fn new(size_px: (u32, u32)) -> Self {
+        unsafe {
+            let t = types();
+            let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
+            let mut err: *mut GError = std::ptr::null_mut();
+            assert!(
+                wpe_display_connect(display, &mut err) != 0,
+                "wpe_display_connect failed"
+            );
+
+            // Ephemeral: mail content must leave no cookie jar and no cache.
+            let session = webkit_network_session_new_ephemeral();
+
+            let pending = Rc::new(RefCell::new(Pending::default()));
+            let sink = pending.clone();
+            FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
+                if let Some(f) = read_shm(buffer) {
+                    // Replace, never accumulate: the newest frame wins.
+                    sink.borrow_mut().frame = Some(f);
+                }
+            }));
+
+            let toplevel = wpe_display_create_toplevel(display, 1);
+            wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
+
+            let filter = compile_block_filter();
+
+            Self {
+                display,
+                toplevel,
+                webview: None,
+                session,
+                ucm: webkit_user_content_manager_new(),
+                filter,
+                size_px,
+                scale: 1.0,
+                pending,
+                poll: GlibPoll::new()
+                    .map_err(|e| eprintln!("cce-mail: no GLib epoll bridge ({e}); pump will poll"))
+                    .ok(),
+                links: Rc::new(RefCell::new(Vec::new())),
+                html: None,
+                images_allowed: false,
+                image: None,
+                last_frame: None,
+            }
+        }
+    }
+
+    /// The webview, created on first use — this is what spawns WebKit's
+    /// child processes, so it only happens once HTML actually arrives.
+    fn ensure_view(&mut self) -> (*mut WebKitWebView, *mut WPEView) {
+        if let Some(pair) = self.webview {
+            return pair;
+        }
+        unsafe {
+            let (p_display, p_ucm, p_session) = (
+                cstr("display"),
+                cstr("user-content-manager"),
+                cstr("network-session"),
+            );
+            let wv = g_object_new(
+                webkit_web_view_get_type(),
+                p_display.as_ptr(),
+                self.display,
+                p_ucm.as_ptr(),
+                self.ucm,
+                p_session.as_ptr(),
+                self.session,
+                std::ptr::null::<c_char>(),
+            ) as *mut WebKitWebView;
+
+            // The lockdown. JavaScript stays off for the life of the view;
+            // images follow `images_allowed`.
+            let settings = webkit_web_view_get_settings(wv);
+            webkit_settings_set_enable_javascript(settings, 0);
+            webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+            self.apply_filter_policy();
+
+            // Link clicks leave through the app, never navigate in-pane.
+            let sig = cstr("decide-policy");
+            g_signal_connect_data(
+                wv as *mut _,
+                sig.as_ptr(),
+                Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
+                    on_decide_policy
+                        as unsafe extern "C" fn(
+                            *mut WebKitWebView,
+                            *mut WebKitPolicyDecision,
+                            WebKitPolicyDecisionType::Type,
+                            gpointer,
+                        ) -> gboolean,
+                )),
+                Rc::into_raw(self.links.clone()) as gpointer,
+                Some(drop_links_ref),
+                0,
+            );
+
+            let view = webkit_web_view_get_wpe_view(wv);
+            wpe_view_set_toplevel(view, self.toplevel);
+            let (lw, lh) = self.logical_size();
+            wpe_view_resized(view, lw, lh);
+            wpe_view_set_visible(view, 1);
+            wpe_view_map(view);
+            // Without focus the page has no focused frame and forwarded
+            // keyboard input (PageDown, Ctrl+C) is silently dropped.
+            wpe_view_focus_in(view);
+            self.webview = Some((wv, view));
+            (wv, view)
+        }
+    }
+
+    /// Attach or detach the block-everything filter to match
+    /// `images_allowed`. WebKit applies UCM changes to live pages.
+    fn apply_filter_policy(&self) {
+        if self.filter.is_null() {
+            return;
+        }
+        unsafe {
+            if self.images_allowed {
+                webkit_user_content_manager_remove_all_filters(self.ucm);
+            } else {
+                webkit_user_content_manager_add_filter(self.ucm, self.filter);
+            }
+        }
+    }
+
+    /// Show a message. Always re-arms the remote-content block: allowing
+    /// images is a per-message decision, never a sticky one.
+    pub fn load_html(&mut self, html: &str) {
+        self.images_allowed = false;
+        // NUL bytes would truncate the CString; they carry no meaning in
+        // HTML, so strip rather than fail.
+        let owned;
+        let clean = if html.contains('\0') {
+            owned = html.replace('\0', "");
+            owned.as_str()
+        } else {
+            html
+        };
+        self.html = Some(unsafe { cstr(clean) });
+        self.reload_current();
+    }
+
+    /// Drop the shown message (selection cleared / folder switched). The
+    /// view and its processes stay for the next message.
+    pub fn clear(&mut self) {
+        self.html = None;
+        self.links.borrow_mut().clear();
+        self.pending.borrow_mut().frame = None;
+        if let Some((id, ..)) = self.image.take() {
+            cce_ui::vk::free_image(id);
+        }
+        if let Some((wv, _)) = self.webview {
+            unsafe {
+                let blank = cstr("about:blank");
+                webkit_web_view_load_uri(wv, blank.as_ptr());
+            }
+        }
+    }
+
+    /// Lift (or restore) the remote-content block for the current message
+    /// and re-render it.
+    pub fn set_images_allowed(&mut self, allowed: bool) {
+        if allowed == self.images_allowed {
+            return;
+        }
+        self.images_allowed = allowed;
+        self.apply_filter_policy();
+        self.reload_current();
+    }
+
+    pub fn images_allowed(&self) -> bool {
+        self.images_allowed
+    }
+
+    fn reload_current(&mut self) {
+        let Some(html) = self.html.clone() else { return };
+        let (wv, _) = self.ensure_view();
+        unsafe {
+            let settings = webkit_web_view_get_settings(wv);
+            webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+            webkit_web_view_load_html(wv, html.as_ptr(), std::ptr::null());
+        }
+        // The old message's frame must not linger under the new one — the
+        // app falls back to the text body until the first frame lands.
+        self.pending.borrow_mut().frame = None;
+        if let Some((id, ..)) = self.image.take() {
+            cce_ui::vk::free_image(id);
+        }
+    }
+
+    /// A link the user clicked in the message, if any (FIFO).
+    pub fn take_link_click(&self) -> Option<String> {
+        let mut links = self.links.borrow_mut();
+        (!links.is_empty()).then(|| links.remove(0))
+    }
+
+    /// The epoll fd carrying GLib's pollfd set, duplicated for calloop.
+    /// `None` if the bridge could not be created — fall back to the timer.
+    pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
+        let fd = self.poll.as_ref()?.fd();
+        rustix::io::dup(fd).ok()
+    }
+
+    /// How long calloop may sleep before pumping anyway, per GLib.
+    pub fn poll_timeout(&self) -> Option<std::time::Duration> {
+        self.poll
+            .as_ref()
+            .and_then(|p| p.timeout)
+            .map(|ms| std::time::Duration::from_millis(ms as u64))
+    }
+
+    /// Drain GLib's pending work, then upload any frame it produced.
+    /// Returns true when a new frame landed (the pane needs a repaint).
+    pub fn pump(&mut self) -> bool {
+        // Clear the inner epoll first: calloop is level-triggered on that fd,
+        // so leaving it readable across a pump that does not consume the
+        // underlying socket would spin the loop.
+        if let Some(p) = &self.poll {
+            p.drain();
+        }
+        unsafe {
+            while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
+        }
+        // WebKit opens and drops sockets as it loads, so the set that matters
+        // is the one *after* dispatch, not before.
+        if let Some(p) = &mut self.poll {
+            p.sync();
+        }
+        let Some((px, w, h)) = self.pending.borrow_mut().frame.take() else {
+            return false;
+        };
+        self.last_frame = Some((px.clone(), w, h));
+        let id = cce_ui::vk::upload_rgba(px, w, h);
+        if let Some((old, ..)) = self.image.replace((id, w, h)) {
+            cce_ui::vk::free_image(old);
+        }
+        true
+    }
+
+    /// The current frame in the image registry: (id, w px, h px).
+    pub fn image(&self) -> Option<(u32, u32, u32)> {
+        self.image
+    }
+
+    /// A pixel of the last frame, for tests asserting on rendered output
+    /// (examples/wpe_mail.rs; dead in the app build).
+    #[allow(dead_code)]
+    pub fn sample_pixel(&self, x: u32, y: u32) -> Option<(u8, u8, u8)> {
+        let (px, w, h) = self.last_frame.as_ref()?;
+        if x >= *w || y >= *h {
+            return None;
+        }
+        let i = ((y * w + x) * 4) as usize;
+        Some((px[i], px[i + 1], px[i + 2]))
+    }
+
+    /// Put the page's current selection on the system clipboard (the
+    /// clipboard subclass routes it through cce-ui's wl-copy helper).
+    pub fn copy_selection(&self) {
+        if let Some((wv, _)) = self.webview {
+            unsafe {
+                let c = cstr("Copy");
+                webkit_web_view_execute_editing_command(wv, c.as_ptr());
+            }
+        }
+    }
+
+    // ---- input ----
+    //
+    // Coordinates are device pixels relative to the view origin (the
+    // browser's convention); the host converts to WPE's logical space.
+
+    pub fn mouse_move(&mut self, x_px: f32, y_px: f32) {
+        let Some((_, view)) = self.webview else { return };
+        unsafe {
+            let (x, y) = self.to_logical(x_px, y_px);
+            let e = wpe_event_pointer_move_new(
+                WPEEventType::WPE_EVENT_POINTER_MOVE,
+                view,
+                WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+                input::now_ms(),
+                0,
+                x,
+                y,
+                0.0,
+                0.0,
+            );
+            self.send(view, e);
+        }
+    }
+
+    pub fn mouse_button_ui(&mut self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
+        let Some(n) = input::button_number(button) else {
+            return;
+        };
+        let Some((_, view)) = self.webview else { return };
+        unsafe {
+            let time = input::now_ms();
+            let (x, y) = self.to_logical(x_px, y_px);
+            // WPE tracks double/triple clicks for us; a frozen clock here
+            // would make every click read as a repeat.
+            let press_count = if pressed {
+                wpe_view_compute_press_count(view, x, y, n, time)
+            } else {
+                0
+            };
+            let e = wpe_event_pointer_button_new(
+                if pressed {
+                    WPEEventType::WPE_EVENT_POINTER_DOWN
+                } else {
+                    WPEEventType::WPE_EVENT_POINTER_UP
+                },
+                view,
+                WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+                time,
+                0,
+                n,
+                x,
+                y,
+                press_count,
+            );
+            self.send(view, e);
+        }
+    }
+
+    /// Wheel deltas in device pixels, winit-signed (positive = up), passed
+    /// through unchanged — WPE inverts on the way to the DOM itself.
+    pub fn wheel(&mut self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
+        let Some((_, view)) = self.webview else { return };
+        unsafe {
+            let (x, y) = self.to_logical(x_px, y_px);
+            let e = wpe_event_scroll_new(
+                view,
+                WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+                input::now_ms(),
+                0,
+                dx_px / self.scale as f64,
+                dy_px / self.scale as f64,
+                1, // precise deltas: these are pixels, not notches
+                0, // not a scroll-stop event
+                x,
+                y,
+            );
+            self.send(view, e);
+        }
+    }
+
+    /// Forward a cce-ui key event (page scrolling, copy chords).
+    pub fn key_ui(&mut self, event: &KeyEvent) {
+        let Some(keyval) = input::keyval(&event.logical_key) else {
+            return;
+        };
+        let Some((_, view)) = self.webview else { return };
+        let pressed = input::is_pressed(event);
+        unsafe {
+            let e = wpe_event_keyboard_new(
+                if pressed {
+                    WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
+                } else {
+                    WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
+                },
+                view,
+                WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
+                input::now_ms(),
+                input::modifiers(event.ctrl, event.shift, event.alt),
+                0, // hardware keycode: unknown to us, WebKit works off keyval
+                keyval,
+            );
+            self.send(view, e);
+        }
+    }
+
+    unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
+        if event.is_null() {
+            return;
+        }
+        wpe_view_event(view, event);
+        wpe_event_unref(event);
+    }
+
+    /// Resize, in **physical** pixels plus the scale. WPE wants a logical
+    /// size and produces a buffer of `size * scale` — handing it physical
+    /// pixels at scale 1 would lay out double-width CSS on a 2x display.
+    pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
+        let size = (width_px.max(1), height_px.max(1));
+        let scale = scale.max(0.01);
+        if size == self.size_px && (scale - self.scale).abs() < 1.0e-3 {
+            return;
+        }
+        self.size_px = size;
+        self.scale = scale;
+        let (lw, lh) = self.logical_size();
+        unsafe {
+            wpe_toplevel_scale_changed(self.toplevel, self.scale as f64);
+            wpe_toplevel_resized(self.toplevel, lw, lh);
+            if let Some((_, view)) = self.webview {
+                wpe_view_resized(view, lw, lh);
+            }
+        }
+    }
+
+    /// The view size WPE works in: physical divided back out by the scale.
+    fn logical_size(&self) -> (i32, i32) {
+        (
+            ((self.size_px.0 as f32 / self.scale).round() as i32).max(1),
+            ((self.size_px.1 as f32 / self.scale).round() as i32).max(1),
+        )
+    }
+
+    fn to_logical(&self, x_px: f32, y_px: f32) -> (f64, f64) {
+        ((x_px / self.scale) as f64, (y_px / self.scale) as f64)
+    }
+}
+
+/// Compile (or load from WebKit's cache) the block-remote content filter.
+///
+/// The store API is async; the surrounding code is a constructor with a GLib
+/// context and nothing else running on it yet, so this blocks on bounded
+/// context iterations until the callback lands. Null on failure — the caller
+/// degrades to `auto-load-images` alone.
+unsafe fn compile_block_filter() -> *mut WebKitUserContentFilter {
+    struct Slot {
+        done: Cell<bool>,
+        filter: Cell<*mut WebKitUserContentFilter>,
+    }
+    unsafe extern "C" fn on_saved(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
+        let slot = &*(data as *const Slot);
+        let mut err: *mut GError = std::ptr::null_mut();
+        let f = webkit_user_content_filter_store_save_finish(
+            source as *mut WebKitUserContentFilterStore,
+            res,
+            &mut err,
+        );
+        if f.is_null() {
+            let msg = (!err.is_null())
+                .then(|| from_cstr((*err).message))
+                .flatten()
+                .unwrap_or_else(|| "unknown error".into());
+            eprintln!("cce-mail: content filter failed to compile ({msg})");
+            if !err.is_null() {
+                g_error_free(err);
+            }
+        }
+        slot.filter.set(f);
+        slot.done.set(true);
+    }
+
+    let dir = std::env::var_os("XDG_STATE_HOME")
+        .map(std::path::PathBuf::from)
+        .filter(|p| p.is_absolute())
+        .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state")))
+        .map(|p| p.join("cce/mail/content-filters"));
+    let Some(dir) = dir else {
+        eprintln!("cce-mail: no HOME; remote-content filter disabled");
+        return std::ptr::null_mut();
+    };
+    let _ = std::fs::create_dir_all(&dir);
+
+    let cdir = cstr(&dir.to_string_lossy());
+    let store = webkit_user_content_filter_store_new(cdir.as_ptr());
+    let id = cstr("block-remote");
+    let bytes = g_bytes_new(
+        BLOCK_REMOTE_FILTER.as_ptr() as *const c_void,
+        BLOCK_REMOTE_FILTER.len() as u64,
+    );
+    let slot = Box::new(Slot {
+        done: Cell::new(false),
+        filter: Cell::new(std::ptr::null_mut()),
+    });
+    webkit_user_content_filter_store_save(
+        store,
+        id.as_ptr(),
+        bytes,
+        std::ptr::null_mut(),
+        Some(on_saved),
+        slot.as_ref() as *const Slot as gpointer,
+    );
+    // Blocking iterations; the cap turns a wedged store into a filterless
+    // start instead of a hang.
+    for _ in 0..10_000 {
+        if slot.done.get() {
+            break;
+        }
+        g_main_context_iteration(std::ptr::null_mut(), 1);
+    }
+    g_bytes_unref(bytes);
+    g_object_unref(store as *mut _);
+    if !slot.done.get() {
+        eprintln!("cce-mail: content filter compile timed out; remote loads gated by image setting only");
+        // The callback may still fire later against the leaked slot.
+        Box::leak(slot);
+        return std::ptr::null_mut();
+    }
+    slot.filter.get()
+}
+
+unsafe extern "C" fn drop_links_ref(data: gpointer, _c: *mut GClosure) {
+    drop(Rc::from_raw(data as *const RefCell<Vec<String>>));
+}
+
+/// Every navigation decision. The initial `load_html` arrives as type OTHER
+/// and passes; a clicked link is stashed for external opening; everything
+/// else (forms, window.open targets) is refused outright.
+unsafe extern "C" fn on_decide_policy(
+    _wv: *mut WebKitWebView,
+    decision: *mut WebKitPolicyDecision,
+    kind: WebKitPolicyDecisionType::Type,
+    data: gpointer,
+) -> gboolean {
+    let links = &*(data as *const RefCell<Vec<String>>);
+    match kind {
+        WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION
+        | WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION => {
+            let nav = decision as *mut WebKitNavigationPolicyDecision;
+            let action = webkit_navigation_policy_decision_get_navigation_action(nav);
+            let ty = webkit_navigation_action_get_navigation_type(action);
+            let is_click = ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_LINK_CLICKED;
+            let in_new_window =
+                kind == WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION;
+            if is_click || in_new_window {
+                let req = webkit_navigation_action_get_request(action);
+                if let Some(uri) = from_cstr(webkit_uri_request_get_uri(req)) {
+                    links.borrow_mut().push(uri);
+                }
+                webkit_policy_decision_ignore(decision);
+            } else if ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_OTHER {
+                // The app's own load_html / about:blank clears.
+                webkit_policy_decision_use(decision);
+            } else {
+                // Form submits, reloads, back/forward: nothing a mail pane
+                // should ever do.
+                webkit_policy_decision_ignore(decision);
+            }
+        }
+        _ => {
+            webkit_policy_decision_use(decision);
+        }
+    }
+    1
+}
+
+/// Copy an SHM buffer's pixels out as RGBA for `upload_rgba`.
+///
+/// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, and the
+/// stride is not assumed to equal `width * 4`.
+unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
+    if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
+        return None;
+    }
+    let shm = buffer as *mut WPEBufferSHM;
+    let (w, h) = (
+        wpe_buffer_get_width(buffer) as u32,
+        wpe_buffer_get_height(buffer) as u32,
+    );
+    let mut len: u64 = 0;
+    let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
+    if src.is_null() || w == 0 || h == 0 {
+        return None;
+    }
+    let stride = wpe_buffer_shm_get_stride(shm) as usize;
+    let mut out = vec![0u8; (w * h * 4) as usize];
+    for y in 0..h as usize {
+        for x in 0..w as usize {
+            let s = src.add(y * stride + x * 4);
+            let d = (y * w as usize + x) * 4;
+            out[d] = *s.add(2);
+            out[d + 1] = *s.add(1);
+            out[d + 2] = *s;
+            out[d + 3] = *s.add(3);
+        }
+    }
+    Some((out, w, h))
+}
diff --git a/src/wpe/input.rs b/src/wpe/input.rs
new file mode 100644
index 0000000..506652e
--- /dev/null
+++ b/src/wpe/input.rs
@@ -0,0 +1,118 @@
+//! Translating cce-ui input into `WPEEvent`s.
+//!
+//! Unlike the Servo backend — where `main.rs` carried `dom_key`/`dom_button`
+//! helpers and the host took engine types — the mapping lives *here* and
+//! [`super::WebKitHost`] takes cce-ui's own `MouseButton` / `KeyEvent`. That
+//! keeps engine vocabulary out of the chrome, so switching backends deletes
+//! those helpers from `main.rs` rather than rewriting them.
+//!
+//! **Keyboard is the fiddly part.** `wpe_event_keyboard_new` wants an X11
+//! *keysym* (`keyval`), not a character. Latin-1 codepoints are their own
+//! keysym; anything above maps to `codepoint + 0x0100_0000`; named keys have
+//! fixed `XK_*` values. Getting this wrong is silent — the page just receives
+//! nothing useful.
+
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, NamedKey};
+
+use super::ffi::*;
+
+/// X11 keysyms for the named keys cce-ui reports (`/usr/include/X11/keysymdef.h`).
+mod keysym {
+    pub const BACKSPACE: u32 = 0xff08;
+    pub const TAB: u32 = 0xff09;
+    pub const RETURN: u32 = 0xff0d;
+    pub const ESCAPE: u32 = 0xff1b;
+    pub const SPACE: u32 = 0x0020;
+    pub const HOME: u32 = 0xff50;
+    pub const LEFT: u32 = 0xff51;
+    pub const UP: u32 = 0xff52;
+    pub const RIGHT: u32 = 0xff53;
+    pub const DOWN: u32 = 0xff54;
+    pub const PAGE_UP: u32 = 0xff55;
+    pub const PAGE_DOWN: u32 = 0xff56;
+    pub const END: u32 = 0xff57;
+    pub const DELETE: u32 = 0xffff;
+    pub const F5: u32 = 0xffc2;
+    pub const SHIFT_L: u32 = 0xffe1;
+    pub const CONTROL_L: u32 = 0xffe3;
+    pub const ALT_L: u32 = 0xffe9;
+    pub const SUPER_L: u32 = 0xffeb;
+}
+
+/// A Unicode scalar as an X11 keysym: Latin-1 is identity, the rest is the
+/// codepoint in the 0x01000000 plane.
+fn unicode_keysym(c: char) -> u32 {
+    match c as u32 {
+        cp @ 0x20..=0xff => cp,
+        cp => cp + 0x0100_0000,
+    }
+}
+
+/// cce-ui key -> X11 keysym. `None` for keys with no sensible mapping.
+pub(super) fn keyval(key: &Key) -> Option<u32> {
+    Some(match key {
+        Key::Character(s) => unicode_keysym(s.chars().next()?),
+        Key::Named(n) => match n {
+            NamedKey::Backspace => keysym::BACKSPACE,
+            NamedKey::Tab => keysym::TAB,
+            NamedKey::Enter => keysym::RETURN,
+            NamedKey::Escape => keysym::ESCAPE,
+            NamedKey::Space => keysym::SPACE,
+            NamedKey::ArrowDown => keysym::DOWN,
+            NamedKey::ArrowLeft => keysym::LEFT,
+            NamedKey::ArrowRight => keysym::RIGHT,
+            NamedKey::ArrowUp => keysym::UP,
+            NamedKey::End => keysym::END,
+            NamedKey::Home => keysym::HOME,
+            NamedKey::PageDown => keysym::PAGE_DOWN,
+            NamedKey::PageUp => keysym::PAGE_UP,
+            NamedKey::Delete => keysym::DELETE,
+            NamedKey::Control => keysym::CONTROL_L,
+            NamedKey::Shift => keysym::SHIFT_L,
+            NamedKey::Alt => keysym::ALT_L,
+            NamedKey::Super => keysym::SUPER_L,
+            NamedKey::F5 => keysym::F5,
+        },
+    })
+}
+
+/// X11 button numbering, which is what WPE expects.
+pub(super) fn button_number(b: MouseButton) -> Option<u32> {
+    Some(match b {
+        MouseButton::Left => 1,
+        MouseButton::Middle => 2,
+        MouseButton::Right => 3,
+        // Back/Forward are chrome navigation in `main.rs`, deliberately not
+        // forwarded to the page.
+        _ => return None,
+    })
+}
+
+pub(super) fn modifiers(ctrl: bool, shift: bool, alt: bool) -> WPEModifiers::Type {
+    let mut m: WPEModifiers::Type = 0;
+    if ctrl {
+        m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_CONTROL;
+    }
+    if shift {
+        m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_SHIFT;
+    }
+    if alt {
+        m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_ALT;
+    }
+    m
+}
+
+pub(super) fn is_pressed(e: &KeyEvent) -> bool {
+    e.state == ElementState::Pressed
+}
+
+/// WPE stamps events with a millisecond clock. It only has to be monotonic
+/// and consistent — `wpe_view_compute_press_count` uses it for double-click
+/// detection, so a frozen value would turn every click into a triple-click.
+pub(super) fn now_ms() -> u32 {
+    use std::time::{SystemTime, UNIX_EPOCH};
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_millis() as u32)
+        .unwrap_or(0)
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
new file mode 100644
index 0000000..a3e5459
--- /dev/null
+++ b/src/wpe/mod.rs
@@ -0,0 +1,23 @@
+//! Embedded WPE WebKit for HTML mail (feature `wpe`, on by default).
+//!
+//! `subclass.rs`, `glib_source.rs`, `input.rs` and `wrapper.h` are verbatim
+//! copies of cce-browser's `src/wpe/` — the proven embedding pattern (see
+//! that crate's WPE-PORT.md). Keep them byte-identical to ease a future
+//! extraction into a shared crate; anything mail-specific belongs in
+//! `host.rs`, which replaces the browser's tabbed `WebKitHost` with the
+//! single sandboxed [`host::MailWebView`].
+
+pub mod ffi {
+    #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)]
+    include!(concat!(env!("OUT_DIR"), "/wpe_bindings.rs"));
+}
+
+mod glib_source;
+mod host;
+mod input;
+// dead_code: the copy stays verbatim; mail never pastes into a page, so the
+// browser's clipboard-sync direction goes unused here.
+#[allow(dead_code)]
+mod subclass;
+
+pub use host::MailWebView;
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
new file mode 100644
index 0000000..d9a994c
--- /dev/null
+++ b/src/wpe/subclass.rs
@@ -0,0 +1,307 @@
+//! The three GObject subclasses WPE requires of an embedder.
+//!
+//! WebKit does not hand us a view to render into; it *asks the display for
+//! one*. So embedding means implementing all three of:
+//!
+//! * `WPEDisplay`  — vends the view and the toplevel (`create_view`,
+//!   `create_toplevel`). `WebKitWebView`'s `display` property is
+//!   construct-only and takes this.
+//! * `WPEToplevel` — **owns buffer-format negotiation.** WebKit asks the
+//!   toplevel, not the display. Leave `create_toplevel` NULL and
+//!   `render_buffer` silently never fires, with a perfectly healthy web
+//!   process and no error anywhere.
+//! * `WPEView`     — receives finished frames via `render_buffer`.
+//!
+//! Registration goes through [`register_subclass`] rather than a Rust struct
+//! embedding the parent, because WPE's instance structs are opaque
+//! (`WPE_DECLARE_DERIVABLE_TYPE` typedefs `struct _WPEView` and never defines
+//! it). `g_type_query` reports the parent's sizes at runtime instead, which is
+//! ABI-safe and survives WPE growing a field. The *class* structs are public,
+//! so bindgen lays them out correctly and installing a vfunc is a field set.
+
+use std::ffi::{c_char, c_void, CString};
+
+use super::ffi::*;
+
+/// Register a GObject subclass of `parent`, sized from the runtime type query.
+pub(super) unsafe fn register_subclass(
+    parent: GType,
+    name: &str,
+    class_init: unsafe extern "C" fn(*mut c_void, *mut c_void),
+) -> GType {
+    let mut q: GTypeQuery = std::mem::zeroed();
+    g_type_query(parent, &mut q);
+    assert!(q.type_ != 0, "parent type {name} not registered");
+    let cname = CString::new(name).expect("subclass name");
+    g_type_register_static_simple(
+        parent,
+        cname.as_ptr(),
+        q.class_size,
+        std::mem::transmute::<_, GClassInitFunc>(class_init),
+        q.instance_size,
+        None,
+        0,
+    )
+}
+
+pub(super) const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
+    (a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
+}
+
+/// Registered once, on first host construction. GType registration is
+/// process-wide and re-registering the same name aborts.
+pub(super) struct Types {
+    pub display: GType,
+    pub view: GType,
+    pub toplevel: GType,
+    pub clipboard: GType,
+}
+
+static mut TYPES: Option<Types> = None;
+
+pub(super) unsafe fn types() -> &'static Types {
+    #[allow(static_mut_refs)]
+    if TYPES.is_none() {
+        TYPES = Some(Types {
+            view: register_subclass(wpe_view_get_type(), "CceWpeView", view_class_init),
+            toplevel: register_subclass(
+                wpe_toplevel_get_type(),
+                "CceWpeToplevel",
+                toplevel_class_init,
+            ),
+            display: register_subclass(wpe_display_get_type(), "CceWpeDisplay", display_class_init),
+            clipboard: register_subclass(
+                wpe_clipboard_get_type(),
+                "CceWpeClipboard",
+                clipboard_class_init,
+            ),
+        });
+    }
+    #[allow(static_mut_refs)]
+    TYPES.as_ref().unwrap()
+}
+
+// ---- view ----
+
+/// Set by the host before it creates a webview; `render_buffer` hands frames
+/// here. One host per process for now (see `WebKitHost::new`).
+pub(super) static mut FRAME_SINK: Option<Box<dyn FnMut(*mut WPEBuffer)>> = None;
+
+unsafe extern "C" fn view_render_buffer(
+    view: *mut WPEView,
+    buffer: *mut WPEBuffer,
+    _damage: *const WPERectangle,
+    _n_damage: u32,
+    _error: *mut *mut GError,
+) -> gboolean {
+    #[allow(static_mut_refs)]
+    if let Some(sink) = FRAME_SINK.as_mut() {
+        sink(buffer);
+    }
+    // BOTH halves. `rendered` means displayed, `released` means the memory is
+    // yours again; with only the first the engine produces exactly one frame
+    // and then stalls forever. This is also the backpressure that makes an
+    // unbounded upload queue impossible here.
+    wpe_view_buffer_rendered(view, buffer);
+    wpe_view_buffer_released(view, buffer);
+    1
+}
+
+unsafe extern "C" fn view_class_init(class: *mut c_void, _data: *mut c_void) {
+    (*(class as *mut WPEViewClass)).render_buffer = Some(view_render_buffer);
+}
+
+// ---- toplevel ----
+
+unsafe extern "C" fn toplevel_formats(_t: *mut WPEToplevel) -> *mut WPEBufferFormats {
+    // Mappable ARGB/XRGB linear: what we can read back on the CPU and hand
+    // straight to `cce_ui::vk::upload_rgba`. DMABuf comes later (phase 2).
+    let b = wpe_buffer_formats_builder_new(std::ptr::null_mut());
+    wpe_buffer_formats_builder_append_group(
+        b,
+        std::ptr::null_mut(),
+        WPEBufferFormatUsage::WPE_BUFFER_FORMAT_USAGE_MAPPING,
+    );
+    for cc in [fourcc(b'A', b'R', b'2', b'4'), fourcc(b'X', b'R', b'2', b'4')] {
+        wpe_buffer_formats_builder_append_format(b, cc, 0);
+    }
+    wpe_buffer_formats_builder_end(b)
+}
+
+unsafe extern "C" fn toplevel_resize(t: *mut WPEToplevel, w: i32, h: i32) -> gboolean {
+    wpe_toplevel_resized(t, w, h);
+    1
+}
+
+unsafe extern "C" fn toplevel_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEToplevelClass;
+    (*c).get_preferred_buffer_formats = Some(toplevel_formats);
+    (*c).resize = Some(toplevel_resize);
+}
+
+// ---- display ----
+
+unsafe extern "C" fn display_connect(_d: *mut WPEDisplay, _e: *mut *mut GError) -> gboolean {
+    1
+}
+
+unsafe extern "C" fn display_create_view(d: *mut WPEDisplay) -> *mut WPEView {
+    let prop = CString::new("display").unwrap();
+    g_object_new(types().view, prop.as_ptr(), d, std::ptr::null::<c_char>()) as *mut WPEView
+}
+
+unsafe extern "C" fn display_create_toplevel(
+    d: *mut WPEDisplay,
+    max_views: u32,
+) -> *mut WPEToplevel {
+    let (p1, p2) = (
+        CString::new("display").unwrap(),
+        CString::new("max-views").unwrap(),
+    );
+    g_object_new(
+        types().toplevel,
+        p1.as_ptr(),
+        d,
+        p2.as_ptr(),
+        max_views,
+        std::ptr::null::<c_char>(),
+    ) as *mut WPEToplevel
+}
+
+/// One clipboard per process, cached: `get_clipboard` is called repeatedly
+/// and must return the same object, since WebKit tracks its change count.
+static mut CLIPBOARD: *mut WPEClipboard = std::ptr::null_mut();
+
+unsafe extern "C" fn display_get_clipboard(d: *mut WPEDisplay) -> *mut WPEClipboard {
+    if CLIPBOARD.is_null() {
+        let prop = CString::new("display").unwrap();
+        CLIPBOARD = g_object_new(types().clipboard, prop.as_ptr(), d, std::ptr::null::<c_char>())
+            as *mut WPEClipboard;
+    }
+    CLIPBOARD
+}
+
+unsafe extern "C" fn display_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEDisplayClass;
+    (*c).connect = Some(display_connect);
+    (*c).create_view = Some(display_create_view);
+    (*c).create_toplevel = Some(display_create_toplevel);
+    // Without this, WebKit has no clipboard at all: Ctrl+V in a page reads
+    // nothing and Ctrl+C writes nowhere, silently.
+    (*c).get_clipboard = Some(display_get_clipboard);
+}
+
+// ---- clipboard ----
+//
+// Routed through `cce_ui`'s wl-copy/wl-paste helpers, which is what the Servo
+// backend does too — it keeps the browser on the same clipboard path as the
+// rest of the DE rather than opening a second connection of its own.
+
+/// Formats we answer to. WebKit asks by MIME type; anything textual maps to
+/// the one string the toolkit deals in.
+fn is_text_format(f: &str) -> bool {
+    f.starts_with("text/plain") || f == "UTF8_STRING" || f == "STRING"
+}
+
+unsafe extern "C" fn clipboard_read(
+    _clipboard: *mut WPEClipboard,
+    format: *const c_char,
+) -> *mut GBytes {
+    let format = if format.is_null() {
+        String::new()
+    } else {
+        std::ffi::CStr::from_ptr(format).to_string_lossy().into_owned()
+    };
+    if !is_text_format(&format) {
+        return std::ptr::null_mut();
+    }
+    let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() else {
+        return std::ptr::null_mut();
+    };
+    let bytes = text.into_bytes().into_boxed_slice();
+    let len = bytes.len();
+    // The GBytes owns the buffer and frees it through the notify below.
+    g_bytes_new_with_free_func(
+        Box::into_raw(bytes) as *const c_void,
+        len as u64,
+        Some(free_boxed_bytes),
+        std::ptr::null_mut(),
+    )
+}
+
+unsafe extern "C" fn free_boxed_bytes(p: gpointer) {
+    drop(Box::from_raw(p as *mut u8));
+}
+
+/// Set while we push the system clipboard into WPE, so the `changed` that
+/// results is not echoed straight back out again.
+pub(super) static mut SYNCING: bool = false;
+
+/// Make WPE aware of what the system clipboard holds.
+///
+/// WPE only knows about content it has been *given*: `read` is never called
+/// for a clipboard it believes is empty, which is why paste silently did
+/// nothing until this existed. A native Wayland backend would push this on
+/// every selection change; we do it at the moment it matters — the paste —
+/// rather than polling `wl-paste` in the background forever.
+pub(super) unsafe fn sync_system_clipboard(display: *mut WPEDisplay) {
+    let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() else {
+        return;
+    };
+    let clipboard = wpe_display_get_clipboard(display);
+    if clipboard.is_null() {
+        return;
+    }
+    let content = wpe_clipboard_content_new();
+    let c = CString::new(text).unwrap_or_default();
+    wpe_clipboard_content_set_text(content, c.as_ptr());
+    SYNCING = true;
+    wpe_clipboard_set_content(clipboard, content);
+    SYNCING = false;
+    wpe_clipboard_content_unref(content);
+
+}
+
+/// The page put something on the clipboard. `is_local` distinguishes that
+/// from us being told about someone else's copy — without the check we would
+/// echo a foreign clipboard straight back and clobber it.
+/// The parent `changed`, kept because overriding it without chaining up is
+/// what silently broke paste: `wpe_clipboard_set_content` routes through this
+/// vfunc, and the **base implementation is what actually stores the content
+/// and bumps the change count**. Without the chain-up, `set_content` appeared
+/// to succeed while WPE still reported no formats and an empty clipboard, so
+/// WebKit never even called `read`.
+static mut PARENT_CHANGED: Option<
+    unsafe extern "C" fn(*mut WPEClipboard, *mut GPtrArray, gboolean, *mut WPEClipboardContent),
+> = None;
+
+unsafe extern "C" fn clipboard_changed(
+    clipboard: *mut WPEClipboard,
+    formats: *mut GPtrArray,
+    is_local: gboolean,
+    content: *mut WPEClipboardContent,
+) {
+    if let Some(parent) = PARENT_CHANGED {
+        parent(clipboard, formats, is_local, content);
+    }
+    // SYNCING guards the other direction: we just pushed the system
+    // clipboard in, and copying it straight back out is a pointless round
+    // trip through wl-copy.
+    if is_local == 0 || content.is_null() || SYNCING {
+        return;
+    }
+    // Borrowed from the content, not ours to free.
+    let text = wpe_clipboard_content_get_text(content);
+    if !text.is_null() {
+        let s = std::ffi::CStr::from_ptr(text).to_string_lossy().into_owned();
+        cce_ui::widget::clipboard::copy_to_clipboard(&s);
+    }
+}
+
+unsafe extern "C" fn clipboard_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEClipboardClass;
+    let parent = g_type_class_peek_parent(class as gpointer) as *mut WPEClipboardClass;
+    PARENT_CHANGED = (!parent.is_null()).then(|| (*parent).changed).flatten();
+    (*c).read = Some(clipboard_read);
+    (*c).changed = Some(clipboard_changed);
+}
diff --git a/src/wpe/wrapper.h b/src/wpe/wrapper.h
new file mode 100644
index 0000000..8eca2e0
--- /dev/null
+++ b/src/wpe/wrapper.h
@@ -0,0 +1,5 @@
+/* Bindgen entry point for the WPE port. The two umbrella headers are the
+ * whole surface: webkit.h is the engine, wpe-platform.h is the embedding
+ * layer (WPEDisplay / WPEView / WPEToplevel) that cce-browser subclasses. */
+#include <wpe/webkit.h>
+#include <wpe/wpe-platform.h>