summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md13
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md171
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md139
-rw-r--r--src/messageview.cpp136
-rw-r--r--src/messageview.h42
-rw-r--r--tests/test_messageview.cpp124
6 files changed, 487 insertions, 138 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74a7e90..9131c25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,19 @@ point at which they are stable.
## [Unreleased]
+### Fixed
+
+- Clicking a link in a message opens it in the system browser. Links carrying
+ `target="_blank"`, which is most links in HTML mail, did nothing at all: no
+ error, nothing on screen. Chromium routes those to a new-window request
+ rather than to the navigation handler, and nothing answered it, so the click
+ was discarded. Plain links, as in most text mail, were unaffected and already
+ worked, which is what made this look like "HTML mail is broken".
+- The context menu on a link no longer offers Open in new tab, Open in new
+ window or Open in this window. None can work: the pane has no tabs and must
+ never open a window or navigate away from the message. Copy link address is
+ kept.
+
## [0.26.1] - 2026-08-19
A bugfix release for one defect in 0.26.0, reported the day it shipped: moving
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 4a4b1ff..4f361bf 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -5140,6 +5140,177 @@ set explicitly. And the procedure's own first step, holding `/tmp/mbsync.lock`,
conflicts with hand-testing Delete, which auto-syncs; that surfaced item 125.
The procedure lives outside this repo, in the user's own documents.
+## 126. A link with `target="_blank"` does nothing when clicked
+
+**Observed (user, 2026-08-20):** "if I click on a link in a mail in the message
+pane, nothing happens." Then, on checking: a plain-text mail (a GitHub PR reply)
+opens its links correctly, while an HTML mail showing the link as a button does
+not.
+
+**That second observation overturned the first diagnosis and is the whole
+item.** This entry originally blamed `RequestInterceptor` blocking `https`.
+That was wrong: the interceptor does deny `https`, but only for resources the
+document FETCHES. A clicked link never becomes a request, because
+`acceptNavigationRequest` calls `QDesktopServices::openUrl` and returns false
+before anything is issued. If the interceptor were the cause, the GitHub link
+could not work either, and it does.
+
+**Cause (verified against the two messages the user named).** The difference is
+`target="_blank"`:
+
+| message | `target` | route taken | result |
+|---|---|---|---|
+| GitHub PR reply | none on any anchor | `acceptNavigationRequest` | opens correctly |
+| HTML newsletter | `_blank` on every anchor | `createWindow()` | silently dropped |
+
+An anchor with no target navigates the main frame, so it reaches
+`MessagePage::acceptNavigationRequest` as `NavigationTypeLinkClicked` and is
+handed to the browser. An anchor asking for a new window does NOT: Chromium
+routes it to `QWebEnginePage::createWindow()`, which `MessagePage` does not
+override, so the base implementation returns `nullptr` and the click is
+discarded. `acceptNavigationRequest` is never consulted, which is why no
+existing code sees it and nothing at all happens.
+
+Both messages render HTML, so this is not a text-versus-HTML distinction: the
+GitHub mail is `multipart/alternative` and its HTML part is what the pane shows.
+Marketing HTML uses `target="_blank"` almost universally, which is why it reads
+as "HTML mail is broken".
+
+**Approach.** Override `createWindow()` in `MessagePage` to hand the URL to the
+same path a plain link takes and return `nullptr`, so no window is ever created.
+
+**One trap, and it decides the shape.** `createWindow()` is called WITHOUT the
+target URL in Qt: the signature carries only a `WebWindowType`. The URL arrives
+afterwards, as a navigation request on the page the override is expected to
+return. Returning `nullptr` therefore discards the URL before it can be seen, so
+the override cannot simply read it. Two known ways around it, and this needs
+measuring before choosing:
+
+- Keep a `linkHovered` cache and use the last hovered URL. Cheap, and wrong if
+ the click arrives without a hover (keyboard activation, synthetic click).
+- Return a throwaway `QWebEnginePage` whose `acceptNavigationRequest` hands the
+ URL to `openUrl` and refuses, then deletes itself. Correct by construction,
+ since the URL arrives through the normal path, at the cost of a short-lived
+ page object.
+
+The second is the one to verify first: it reuses the code that already works for
+plain links rather than adding a second, differently-sourced route to the same
+action.
+
+**Constraints.**
+
+- **No second `QWebEngineView` may be created**, whatever shape this takes. The
+ pane renders a list into one view precisely to avoid one Chromium render
+ process per message.
+- **The pane must never navigate.** Whatever handles the URL must still refuse
+ the navigation, exactly as the plain-link path does.
+- **`m_allowRemote` stays false.** Nothing here needs the interceptor relaxed:
+ the URL goes to an external browser and the pane fetches nothing. The earlier
+ draft of this item proposed loosening the interceptor and would have weakened
+ the remote-content protection for no reason.
+- **A `mailto:` link is still item 123's question**, and marketing HTML carries
+ those with `target="_blank"` too.
+
+**Verification.** A test can assert that a `target="_blank"` anchor reaches
+whatever handler is chosen; it cannot assert the browser opened. The regression
+that matters is the routing, and it is invisible today because nothing observes
+`createWindow()` at all.
+
+**Size: S.**
+
+## 127. A link's context menu offers four browser actions that cannot work
+
+**Observed (user, 2026-08-20):** right-clicking a link still offers "Open in new
+tab", "open in new window", "save link", "copy link" and "select all". The user
+identified them as probable survivors of an earlier removal, which is exactly
+what they are.
+
+**Cause (verified).** Item 100 removed the page-level browser actions, and its
+list is explicit (`src/messageview.cpp:689-694`): `Back`, `Forward`, `Reload`,
+`SavePage`. Those four are what a standard menu offers on the PAGE. The
+link-specific actions are different `WebAction` values entirely
+(`QWebEnginePage::OpenLinkInNewTab`, `OpenLinkInNewWindow`, `DownloadLinkToDisk`,
+`CopyLinkToClipboard`), and Chromium adds them only when the menu is raised over
+a link. Item 100 was tested by right-clicking the page, so they were never in
+the menu it was filtering and were never considered.
+
+**Two of them are dead and two are not**, which is why this is not a single
+sweep:
+
+- `OpenLinkInNewTab` and `OpenLinkInNewWindow` cannot work at all. There are no
+ tabs, and a new window means a second `QWebEngineView`, which the pane
+ deliberately does not create (one Chromium render process per message is the
+ reason the pane renders a list into one view). Both are dead UI today.
+- `DownloadLinkToDisk` needs a `downloadRequested` handler, which item 114
+ records does not exist anywhere. It is dead for the same reason Save image is,
+ and should be decided WITH item 114 rather than separately.
+- `CopyLinkToClipboard` works and is useful. It is the user's entire workaround
+ for item 126 on the messages that fail, by their own description.
+
+**Approach.** Remove `OpenLinkInNewTab` and `OpenLinkInNewWindow` by pointer,
+the way `removeBrowserActions()` already does, so the removal survives
+translation. Keep `CopyLinkToClipboard`. Leave `DownloadLinkToDisk` to item 114.
+
+**This is downstream of item 126 and should follow it.** Note that
+`OpenLinkInNewTab` and `OpenLinkInNewWindow` fail through the SAME missing
+`createWindow()` that 126 is about, so fixing 126 may well make both of them
+start working. That would be worse rather than better: the pane must not open
+windows, and two menu entries silently doing what one click should do is not the
+design. Decide their fate after 126 lands, when it is known what they do rather
+than what they fail to do. Removing them first and adding one back afterwards is
+two changes to the same menu.
+
+**Constraints.**
+
+- **Filter by `pageAction()` POINTER, never by text.** Item 100 established
+ this and the reason is translation: the menu is Italian under `LANG=it_IT`
+ and a text match would silently stop matching.
+- **Stranded separators must still be swept**, which `removeBrowserActions()`
+ already handles; removing two adjacent entries is exactly the case that
+ leaves one behind.
+- **The call site cannot be tested**, per item 117: `createStandardContextMenu()`
+ returns nothing outside a real context-menu event. Assert on the filter
+ function against a menu built by hand, and state the gap rather than faking
+ coverage.
+
+**Size: XS**, and smaller still if done in the same sitting as 126.
+
+**Closed 2026-08-20, unreleased, both items in one sitting.**
+
+126: `MessagePage::createWindow()` returns a `LinkRelayPage`, a page with no
+view whose `acceptNavigationRequest` hands the URL to
+`MessageView::openExternally()` and refuses, then deletes itself. The relay
+exists because `createWindow()` receives only a `WebWindowType`: the URL
+arrives afterwards as a navigation on the returned page, so an override
+returning `nullptr` discards it before it can be read. Routing it through the
+same handler the plain-link path uses is what keeps the two kinds of link from
+drifting apart.
+
+127: three actions added to `removeBrowserActions()`'s list,
+`CopyLinkToClipboard` deliberately left. The order mattered: 126 gives the page
+a real `createWindow()`, so those entries would have stopped being merely dead
+and started opening links into a tab that does not exist.
+
+**Testing needed two seams, and the reason is worth keeping.** The click cannot
+be synthesised: JavaScript is off in this profile, so `element.click()` does
+nothing (measured, `runJavaScript` returns an invalid `QVariant`), and a
+synthetic mouse press would have to land on the anchor's rect, which depends on
+the desktop's fonts. `setUrl()` is no substitute either, since it arrives as
+`NavigationTypeTyped` and takes the branch that accepts our own document load.
+So `clickLinkForTest()` and `relayBlankTargetForTest()` drive the real overrides
+on the real page, and `setLinkOpener()` substitutes a recorder for
+`QDesktopServices::openUrl`, which would otherwise launch a browser.
+
+Both routes are asserted, not just the broken one: they end at the same handler
+now, so breaking the working one while fixing the other was the plausible
+regression. Three mutations checked, all caught: `createWindow` returning
+`nullptr` fails the `_blank` test with the message naming the real defect, the
+filter losing its link actions fails one direction, and the filter also removing
+`CopyLinkToClipboard` fails the other. That last one matters because Copy link
+is the user's fallback for any link that will not open, and a future sweep of
+"dead link actions" would otherwise take it silently.
+
+
## 90. A saved-query button clears the account selection
**Observed (user, notes):** "select an account and hit the 'unread' button, the
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index f56f037..3848a6d 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -195,8 +195,8 @@ taking that too literally.
| 125 | A skipped sync leaves the spinner running for ever | defect | S | open, 2026-08-20, found by hand. `mailsync.sh` exits 75 (EX_TEMPFAIL) when another run holds the lock; the indicator never clears, and a held edit waits for a completion that never comes |
-| 126 | A link with `target="_blank"` does nothing when clicked | defect | S | open, 2026-08-20. `createWindow()` is not overridden, so Chromium drops the click before `acceptNavigationRequest` sees it. Plain links work; marketing HTML uses `_blank` almost universally |
-| 127 | A link's context menu offers four browser actions that cannot work | defect | XS | open, 2026-08-20. Item 100 removed the page-level actions and never saw these: they appear only on a link. Follows 126, which decides which of them should survive |
+| 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup |
+| 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1056,141 +1056,6 @@ Then Delete a message. Verified by hand on 2026-08-20; this is how it was found.
**Size: S.**
-## 126. A link with `target="_blank"` does nothing when clicked
-
-**Observed (user, 2026-08-20):** "if I click on a link in a mail in the message
-pane, nothing happens." Then, on checking: a plain-text mail (a GitHub PR reply)
-opens its links correctly, while an HTML mail showing the link as a button does
-not.
-
-**That second observation overturned the first diagnosis and is the whole
-item.** This entry originally blamed `RequestInterceptor` blocking `https`.
-That was wrong: the interceptor does deny `https`, but only for resources the
-document FETCHES. A clicked link never becomes a request, because
-`acceptNavigationRequest` calls `QDesktopServices::openUrl` and returns false
-before anything is issued. If the interceptor were the cause, the GitHub link
-could not work either, and it does.
-
-**Cause (verified against the two messages the user named).** The difference is
-`target="_blank"`:
-
-| message | `target` | route taken | result |
-|---|---|---|---|
-| GitHub PR reply | none on any anchor | `acceptNavigationRequest` | opens correctly |
-| HTML newsletter | `_blank` on every anchor | `createWindow()` | silently dropped |
-
-An anchor with no target navigates the main frame, so it reaches
-`MessagePage::acceptNavigationRequest` as `NavigationTypeLinkClicked` and is
-handed to the browser. An anchor asking for a new window does NOT: Chromium
-routes it to `QWebEnginePage::createWindow()`, which `MessagePage` does not
-override, so the base implementation returns `nullptr` and the click is
-discarded. `acceptNavigationRequest` is never consulted, which is why no
-existing code sees it and nothing at all happens.
-
-Both messages render HTML, so this is not a text-versus-HTML distinction: the
-GitHub mail is `multipart/alternative` and its HTML part is what the pane shows.
-Marketing HTML uses `target="_blank"` almost universally, which is why it reads
-as "HTML mail is broken".
-
-**Approach.** Override `createWindow()` in `MessagePage` to hand the URL to the
-same path a plain link takes and return `nullptr`, so no window is ever created.
-
-**One trap, and it decides the shape.** `createWindow()` is called WITHOUT the
-target URL in Qt: the signature carries only a `WebWindowType`. The URL arrives
-afterwards, as a navigation request on the page the override is expected to
-return. Returning `nullptr` therefore discards the URL before it can be seen, so
-the override cannot simply read it. Two known ways around it, and this needs
-measuring before choosing:
-
-- Keep a `linkHovered` cache and use the last hovered URL. Cheap, and wrong if
- the click arrives without a hover (keyboard activation, synthetic click).
-- Return a throwaway `QWebEnginePage` whose `acceptNavigationRequest` hands the
- URL to `openUrl` and refuses, then deletes itself. Correct by construction,
- since the URL arrives through the normal path, at the cost of a short-lived
- page object.
-
-The second is the one to verify first: it reuses the code that already works for
-plain links rather than adding a second, differently-sourced route to the same
-action.
-
-**Constraints.**
-
-- **No second `QWebEngineView` may be created**, whatever shape this takes. The
- pane renders a list into one view precisely to avoid one Chromium render
- process per message.
-- **The pane must never navigate.** Whatever handles the URL must still refuse
- the navigation, exactly as the plain-link path does.
-- **`m_allowRemote` stays false.** Nothing here needs the interceptor relaxed:
- the URL goes to an external browser and the pane fetches nothing. The earlier
- draft of this item proposed loosening the interceptor and would have weakened
- the remote-content protection for no reason.
-- **A `mailto:` link is still item 123's question**, and marketing HTML carries
- those with `target="_blank"` too.
-
-**Verification.** A test can assert that a `target="_blank"` anchor reaches
-whatever handler is chosen; it cannot assert the browser opened. The regression
-that matters is the routing, and it is invisible today because nothing observes
-`createWindow()` at all.
-
-**Size: S.**
-
-## 127. A link's context menu offers four browser actions that cannot work
-
-**Observed (user, 2026-08-20):** right-clicking a link still offers "Open in new
-tab", "open in new window", "save link", "copy link" and "select all". The user
-identified them as probable survivors of an earlier removal, which is exactly
-what they are.
-
-**Cause (verified).** Item 100 removed the page-level browser actions, and its
-list is explicit (`src/messageview.cpp:689-694`): `Back`, `Forward`, `Reload`,
-`SavePage`. Those four are what a standard menu offers on the PAGE. The
-link-specific actions are different `WebAction` values entirely
-(`QWebEnginePage::OpenLinkInNewTab`, `OpenLinkInNewWindow`, `DownloadLinkToDisk`,
-`CopyLinkToClipboard`), and Chromium adds them only when the menu is raised over
-a link. Item 100 was tested by right-clicking the page, so they were never in
-the menu it was filtering and were never considered.
-
-**Two of them are dead and two are not**, which is why this is not a single
-sweep:
-
-- `OpenLinkInNewTab` and `OpenLinkInNewWindow` cannot work at all. There are no
- tabs, and a new window means a second `QWebEngineView`, which the pane
- deliberately does not create (one Chromium render process per message is the
- reason the pane renders a list into one view). Both are dead UI today.
-- `DownloadLinkToDisk` needs a `downloadRequested` handler, which item 114
- records does not exist anywhere. It is dead for the same reason Save image is,
- and should be decided WITH item 114 rather than separately.
-- `CopyLinkToClipboard` works and is useful. It is the user's entire workaround
- for item 126 on the messages that fail, by their own description.
-
-**Approach.** Remove `OpenLinkInNewTab` and `OpenLinkInNewWindow` by pointer,
-the way `removeBrowserActions()` already does, so the removal survives
-translation. Keep `CopyLinkToClipboard`. Leave `DownloadLinkToDisk` to item 114.
-
-**This is downstream of item 126 and should follow it.** Note that
-`OpenLinkInNewTab` and `OpenLinkInNewWindow` fail through the SAME missing
-`createWindow()` that 126 is about, so fixing 126 may well make both of them
-start working. That would be worse rather than better: the pane must not open
-windows, and two menu entries silently doing what one click should do is not the
-design. Decide their fate after 126 lands, when it is known what they do rather
-than what they fail to do. Removing them first and adding one back afterwards is
-two changes to the same menu.
-
-**Constraints.**
-
-- **Filter by `pageAction()` POINTER, never by text.** Item 100 established
- this and the reason is translation: the menu is Italian under `LANG=it_IT`
- and a text match would silently stop matching.
-- **Stranded separators must still be swept**, which `removeBrowserActions()`
- already handles; removing two adjacent entries is exactly the case that
- leaves one behind.
-- **The call site cannot be tested**, per item 117: `createStandardContextMenu()`
- returns nothing outside a real context-menu event. Assert on the filter
- function against a menu built by hand, and state the gap rather than faking
- coverage.
-
-**Size: XS**, and smaller still if done in the same sitting as 126.
-
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/src/messageview.cpp b/src/messageview.cpp
index a7d340a..3dbd0f1 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -58,6 +58,29 @@
namespace {
+} // namespace
+
+MessageView::LinkOpener &linkOpenerRef()
+{
+ static MessageView::LinkOpener opener;
+ return opener;
+}
+
+void MessageView::setLinkOpener(LinkOpener opener)
+{
+ linkOpenerRef() = std::move(opener);
+}
+
+void MessageView::openExternally(const QUrl &url)
+{
+ if (const LinkOpener &opener = linkOpenerRef())
+ opener(url);
+ else
+ QDesktopServices::openUrl(url);
+}
+
+namespace {
+
/// Intercepts link clicks so a message can never navigate the pane.
class MessagePage : public QWebEnginePage
{
@@ -105,7 +128,7 @@ protected:
return false;
}
- QDesktopServices::openUrl(url);
+ MessageView::openExternally(url);
return false;
}
@@ -114,12 +137,103 @@ protected:
return !isMainFrame;
}
+ /// Item 126. An anchor carrying target="_blank" never reaches
+ /// acceptNavigationRequest: Chromium asks for a new window instead, and
+ /// the base implementation returns nullptr, so the click is discarded with
+ /// no error and nothing on screen. Marketing HTML sets _blank on
+ /// practically every link, which is what made "HTML mail" look broken
+ /// while a plain-text mail's links worked.
+ ///
+ /// The obvious override cannot work: createWindow() is handed a
+ /// WebWindowType and NO url. The target arrives afterwards, as a
+ /// navigation on whatever page is returned, so returning nullptr throws it
+ /// away before it can be read.
+ ///
+ /// So return a page whose only job is to receive that navigation. It
+ /// reuses the same handler the plain-link path uses rather than sourcing
+ /// the URL a second way, which is what keeps the two kinds of link from
+ /// drifting apart. No view is ever created and nothing is ever fetched:
+ /// the page refuses the navigation, and deleteLater() disposes of it once
+ /// the URL has been handed on.
+ QWebEnginePage *createWindow(WebWindowType type) override
+ {
+ return makeRelay(type);
+ }
+
+public:
+ /// The same call Chromium makes, reachable from a test. See
+ /// MessageView::relayBlankTargetForTest().
+ QWebEnginePage *createWindowForTest(WebWindowType type)
+ {
+ return makeRelay(type);
+ }
+
+ /// The same call Chromium makes for a clicked anchor. See
+ /// MessageView::clickLinkForTest().
+ bool clickLinkForTest(const QUrl &url)
+ {
+ return acceptNavigationRequest(url, NavigationTypeLinkClicked, true);
+ }
+
+protected:
+
private:
+ QWebEnginePage *makeRelay(WebWindowType)
+ {
+ return new LinkRelayPage(profile(), this);
+ }
+
+ /// Receives the navigation createWindow() could not see, hands the URL to
+ /// the external browser, and refuses. Never shown, never given a view.
+ class LinkRelayPage : public QWebEnginePage
+ {
+ public:
+ LinkRelayPage(QWebEngineProfile *profile, QObject *parent)
+ : QWebEnginePage(profile, parent) {}
+
+ protected:
+ bool acceptNavigationRequest(const QUrl &url, NavigationType,
+ bool) override
+ {
+ // Whatever the type, this page exists for exactly one URL and is
+ // finished the moment it has it.
+ if (url.isValid() && !url.scheme().isEmpty())
+ MessageView::openExternally(url);
+ deleteLater();
+ return false;
+ }
+ };
+
QueryHandler m_onQuery;
};
} // namespace
+bool MessageView::clickLinkForTest(const QUrl &url)
+{
+ auto *page = static_cast<MessagePage *>(m_view->page());
+ if (!page)
+ return false;
+ return page->clickLinkForTest(url);
+}
+
+bool MessageView::relayBlankTargetForTest(const QUrl &url)
+{
+ // static_cast, not qobject_cast: MessagePage carries no Q_OBJECT, and the
+ // page is one this class constructed itself, so the type is not in doubt.
+ auto *page = static_cast<MessagePage *>(m_view->page());
+ if (!page)
+ return false;
+ // Chromium's own sequence: ask for the window, then navigate it. The type
+ // is what a target="_blank" anchor produces.
+ QWebEnginePage *relay = page->createWindowForTest(
+ QWebEnginePage::WebBrowserTab);
+ if (!relay)
+ return false;
+ relay->setUrl(url);
+ return true;
+}
+
MessageView::MessageView(QWidget *parent)
: QWidget(parent)
{
@@ -691,6 +805,26 @@ void MessageView::removeBrowserActions(QMenu *menu, QWebEnginePage *page)
QWebEnginePage::Forward,
QWebEnginePage::Reload,
QWebEnginePage::SavePage,
+ // Item 127. Chromium adds these only when the menu is raised over a
+ // LINK, so the four above, which are page actions, were the whole list
+ // until now and this was tested by right-clicking the page.
+ //
+ // None of the three can be honoured. There are no tabs, and a window
+ // means a second QWebEngineView, which the pane deliberately never
+ // creates: one view per message is one Chromium render process per
+ // message. "In this window" would navigate the pane away from the
+ // message, which no message may do.
+ //
+ // They became MORE dangerous with item 126, not less: that fix gives
+ // the page a real createWindow(), so an entry that used to be merely
+ // dead would now do something, and what it would do is open a link the
+ // user asked to open in a tab that does not exist.
+ //
+ // CopyLinkToClipboard is deliberately NOT here. It works, and it is
+ // the fallback for any link that still will not open.
+ QWebEnginePage::OpenLinkInNewTab,
+ QWebEnginePage::OpenLinkInNewWindow,
+ QWebEnginePage::OpenLinkInThisWindow,
};
for (const QWebEnginePage::WebAction which : kUnwanted) {
diff --git a/src/messageview.h b/src/messageview.h
index 09fc905..3cc1604 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -20,6 +20,8 @@
#include <QList>
#include <QUrl>
+
+#include <functional>
#include <QTimer>
#include <QWidget>
@@ -57,6 +59,46 @@ public:
/// interceptor fails closed and the pane renders nothing at all.
static QUrl documentUrl() { return QUrl(QStringLiteral("qtmaildir://message")); }
+ /// How a clicked link reaches the outside world.
+ ///
+ /// A seam, because the alternative is untestable: the call sits inside
+ /// MessagePage, ends in QDesktopServices::openUrl(), and a passing test
+ /// would have to launch a real browser. Item 126's regression is about
+ /// WHICH clicks arrive here, not about what openUrl does, so a test
+ /// substitutes a recorder and asserts on the URLs it collects.
+ ///
+ /// Production never sets this; the default opens the system browser.
+ using LinkOpener = std::function<void(const QUrl &)>;
+ static void setLinkOpener(LinkOpener opener);
+ static void openExternally(const QUrl &url);
+
+ /// Asks the pane's page for the window a target="_blank" click wants, and
+ /// drives the returned page with `url` exactly as Chromium would.
+ ///
+ /// A test hook, and it exists because the alternative proves nothing.
+ /// MessagePage lives in an anonymous namespace so createWindow() cannot be
+ /// called directly, and the click itself cannot be synthesised: JavaScript
+ /// is off in this profile (verified, runJavaScript returns an invalid
+ /// QVariant), so `element.click()` does nothing, and a synthetic mouse
+ /// press would have to land on the anchor's rect, which depends on the
+ /// desktop's fonts. This drives the real override on the real page.
+ ///
+ /// Returns false when the page declined to provide one at all, which is
+ /// the pre-item-126 behaviour and the regression worth catching.
+ bool relayBlankTargetForTest(const QUrl &url);
+
+ /// Drives the pane's page with a link click, as
+ /// acceptNavigationRequest() sees one.
+ ///
+ /// The same reasoning as relayBlankTargetForTest(): the click cannot be
+ /// synthesised. setUrl() is no substitute, because it arrives as
+ /// NavigationTypeTyped and takes the branch that accepts our own document
+ /// load, never the link branch.
+ ///
+ /// Returns what the page decided: false means the navigation was refused,
+ /// which is what a link click must always produce here.
+ bool clickLinkForTest(const QUrl &url);
+
/// Renders a whole thread, oldest first. Items whose expanded flag is
/// false collapse to a one-line stub.
void showThread(const QList<ThreadRenderItem> &items);
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 3d21536..c3e21ed 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -63,6 +63,9 @@ private slots:
void theCopyToastAppearsOverThePaneAndFades();
void theCopyToastStaysAnchoredWhenThePaneResizes();
void aSearchFromTheDetailsDialogClosesIt();
+ void aPlainLinkOpensExternally();
+ void aTargetBlankLinkOpensExternally();
+ void theLinkMenuDropsTheOpenInWindowActions();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -1035,5 +1038,126 @@ void TestMessageView::aSearchFromTheDetailsDialogClosesIt()
|| !view.findChild<MessageDetailsDialog *>()->isVisible());
}
+
+// Item 126. A clicked link must leave the pane, and the two kinds of anchor
+// reach the outside world by DIFFERENT routes through Qt. Both are asserted,
+// because the working one is what disproved the first diagnosis: a plain-text
+// mail's links already opened while an HTML newsletter's did nothing, so a
+// test covering one route says nothing about the other.
+//
+// MessageView::setLinkOpener() is the seam. The real call ends in
+// QDesktopServices::openUrl(), which would launch a browser; what is under
+// test is WHICH clicks arrive there, never what openUrl does with them.
+
+void TestMessageView::aPlainLinkOpensExternally()
+{
+ // The route that already worked. Asserted so that fixing the other one
+ // cannot quietly break it, which is the plausible regression: both end at
+ // the same handler now.
+ QList<QUrl> opened;
+ MessageView::setLinkOpener([&opened](const QUrl &u) { opened.append(u); });
+
+ MessageView view;
+
+ // A link click as acceptNavigationRequest sees it. Driven through the page
+ // rather than synthesised: JavaScript is off in this profile, so
+ // element.click() does nothing (verified, runJavaScript returns an invalid
+ // QVariant), and a synthetic mouse press would have to land on the
+ // anchor's rect, which depends on the desktop's fonts.
+ const QUrl target(QStringLiteral("https://example.org/plain"));
+ QVERIFY2(!view.clickLinkForTest(target),
+ "a link click must be REFUSED as a navigation: the pane may "
+ "never follow a link");
+
+ QTRY_VERIFY_WITH_TIMEOUT(!opened.isEmpty(), 5000);
+ QCOMPARE(opened.size(), 1);
+ QCOMPARE(opened.first(), target);
+
+ MessageView::setLinkOpener({});
+}
+
+void TestMessageView::aTargetBlankLinkOpensExternally()
+{
+ // The defect. An anchor carrying target="_blank" never reaches
+ // acceptNavigationRequest: Chromium asks for a new window instead, and the
+ // base createWindow() returns nullptr, so the click was discarded with
+ // nothing on screen and no error anywhere. Marketing HTML sets _blank on
+ // practically every anchor, which is what made "HTML mail" look broken
+ // while a plain-text mail's links worked.
+ QList<QUrl> opened;
+ MessageView::setLinkOpener([&opened](const QUrl &u) { opened.append(u); });
+
+ MessageView view;
+ const QUrl target(QStringLiteral("https://example.org/blank"));
+
+ // Drives the real createWindow() override on the real page, then navigates
+ // what it returns, which is Chromium's own sequence. Before item 126 the
+ // page returned nothing and this is false.
+ QVERIFY2(view.relayBlankTargetForTest(target),
+ "the page provided no window for a target=\"_blank\" click, so "
+ "the URL was discarded");
+
+ QTRY_VERIFY_WITH_TIMEOUT(!opened.isEmpty(), 5000);
+ QCOMPARE(opened.size(), 1);
+ QCOMPARE(opened.first(), target);
+
+ // No second view may exist for it. The pane renders a list into ONE
+ // QWebEngineView deliberately: a view per message is a Chromium render
+ // process per message.
+ QCOMPARE(view.findChildren<QWebEngineView *>().size(), 1);
+
+ MessageView::setLinkOpener({});
+}
+
+void TestMessageView::theLinkMenuDropsTheOpenInWindowActions()
+{
+ // Item 127. Chromium adds these only when the menu is raised over a LINK,
+ // so item 100's filter never saw them: its list is the page actions, and
+ // it was tested by right-clicking the page.
+ //
+ // They are not uniform, which is why this asserts in both directions.
+ // Open in new tab and Open in new window cannot be honoured: there are no
+ // tabs and the pane must never open a window. Copy link works, and is the
+ // whole workaround a user has for any link that will not open, so removing
+ // it would take away the fallback.
+ MessageView view;
+ auto *page = view.findChild<QWebEnginePage *>();
+ QVERIFY2(page, "no page, so this test would assert nothing");
+
+ QMenu menu;
+ const QList<QWebEnginePage::WebAction> unwanted = {
+ QWebEnginePage::OpenLinkInNewTab,
+ QWebEnginePage::OpenLinkInNewWindow,
+ QWebEnginePage::OpenLinkInThisWindow,
+ };
+ const QList<QWebEnginePage::WebAction> wanted = {
+ QWebEnginePage::CopyLinkToClipboard,
+ };
+
+ for (const QWebEnginePage::WebAction which : unwanted)
+ menu.addAction(page->action(which));
+ menu.addSeparator();
+ for (const QWebEnginePage::WebAction which : wanted)
+ menu.addAction(page->action(which));
+
+ // The guard: prove the menu holds what the assertions are about, so a
+ // filter that removed everything cannot pass by accident.
+ QCOMPARE(menu.actions().size(), unwanted.size() + wanted.size() + 1);
+
+ MessageView::removeBrowserActions(&menu, page);
+
+ const QList<QAction *> left = menu.actions();
+ for (const QWebEnginePage::WebAction which : unwanted) {
+ QVERIFY2(!left.contains(page->action(which)),
+ qPrintable(QStringLiteral("a link action survived: %1")
+ .arg(page->action(which)->text())));
+ }
+ for (const QWebEnginePage::WebAction which : wanted) {
+ QVERIFY2(left.contains(page->action(which)),
+ qPrintable(QStringLiteral("a wanted action was removed: %1")
+ .arg(page->action(which)->text())));
+ }
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"