mirror of
https://github.com/flectar/mail.git
synced 2026-11-12 10:15:19 +00:00
300 lines
11 KiB
Rust
300 lines
11 KiB
Rust
//! Host URL dispatch, with separate policies for mail links and authorization.
|
|
|
|
use std::sync::Arc;
|
|
|
|
type OpenUrl = dyn Fn(&str) -> Result<(), String> + Send + Sync;
|
|
|
|
/// The URL service carried by [`crate::PlatformContext`].
|
|
///
|
|
/// The default uses the system browser, preferring Linux's desktop portal for
|
|
/// authorization. Hosts without desktop launchers supply a callback with
|
|
/// [`Self::new`], and hosts with a dedicated sign-in session add it with
|
|
/// [`Self::with_authorization`]. Clones share the callbacks' state.
|
|
#[derive(Clone, Default)]
|
|
pub struct ExternalUrlOpener {
|
|
handler: Option<Arc<OpenUrl>>,
|
|
authorization: Option<Arc<OpenUrl>>,
|
|
}
|
|
|
|
impl ExternalUrlOpener {
|
|
/// Use a host callback for both external links and browser authorization.
|
|
///
|
|
/// Mail links call this on the application event loop; authorization calls
|
|
/// it on a worker thread. Hosts must marshal native API calls to their
|
|
/// required thread. Return promptly after handing the URL to the host,
|
|
/// without waiting for the browser to close. Errors are shown to the user;
|
|
/// a failed host callback never falls back to a desktop launcher.
|
|
pub fn new(opener: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static) -> Self {
|
|
Self {
|
|
handler: Some(Arc::new(opener)),
|
|
authorization: None,
|
|
}
|
|
}
|
|
|
|
/// Open browser authorization in a host session instead, such as iOS's
|
|
/// ASWebAuthenticationSession, which returns the provider's redirect to
|
|
/// the app. Mail links keep the other handler.
|
|
///
|
|
/// The callback runs on a worker thread with the provider's HTTPS address
|
|
/// and must return once the session has been handed to the host.
|
|
pub fn with_authorization(
|
|
mut self,
|
|
authorize: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
|
|
) -> Self {
|
|
self.authorization = Some(Arc::new(authorize));
|
|
self
|
|
}
|
|
|
|
pub(super) fn open_external(&self, url: &str) -> Result<(), String> {
|
|
match &self.handler {
|
|
Some(opener) => opener(url),
|
|
None => webbrowser::open(url).map_err(|error| error.to_string()),
|
|
}
|
|
}
|
|
|
|
pub(super) fn open_authorization(&self, url: &str) -> Result<(), String> {
|
|
let address = url::Url::parse(url).map_err(|_| "Sign-in address is invalid".to_owned())?;
|
|
if address.scheme() != "https" || address.host_str().is_none() {
|
|
return Err("Sign-in requires an HTTPS address".into());
|
|
}
|
|
match self.authorization.as_ref().or(self.handler.as_ref()) {
|
|
Some(opener) => opener(address.as_str()),
|
|
None => open_system_authorization(&address),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(linux_desktop)]
|
|
use std::process::{Command, Stdio};
|
|
|
|
#[cfg(linux_desktop)]
|
|
fn quiet_command(program: &str) -> Command {
|
|
let mut command = Command::new(program);
|
|
command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
command
|
|
}
|
|
|
|
#[cfg(linux_desktop)]
|
|
fn launch(mut command: Command) -> Result<(), String> {
|
|
let mut child = command.spawn().map_err(|e| e.to_string())?;
|
|
// xdg-open may exit before the browser starts, or live as long as the browser.
|
|
// Detect immediate failures, but never block authorization on a browser exit.
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) if status.success() => return Ok(()),
|
|
Ok(Some(status)) => return Err(format!("browser launcher exited with {status}")),
|
|
Err(error) => return Err(error.to_string()),
|
|
Ok(None) if std::time::Instant::now() >= deadline => {
|
|
std::thread::spawn(move || {
|
|
let _ = child.wait();
|
|
});
|
|
return Ok(());
|
|
}
|
|
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(25)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(linux_desktop)]
|
|
fn open_with_desktop_portal(address: &url::Url) -> Result<(), String> {
|
|
let address = address.clone();
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|error| error.to_string())?;
|
|
runtime.block_on(async move {
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), async move {
|
|
let request = ashpd::desktop::open_uri::OpenFileRequest::default()
|
|
.send_uri(&address)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
request.response().map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.map_err(|_| "desktop portal did not respond".to_owned())?
|
|
})
|
|
}
|
|
|
|
fn open_system_authorization(address: &url::Url) -> Result<(), String> {
|
|
let url = address.as_str();
|
|
#[cfg(linux_desktop)]
|
|
{
|
|
// The portal is the reliable route from AppImages and desktop
|
|
// containers to the user's host browser. Keep command-based fallbacks
|
|
// for minimal desktops that do not run xdg-desktop-portal.
|
|
if open_with_desktop_portal(address).is_ok() {
|
|
return Ok(());
|
|
}
|
|
let mut xdg = quiet_command("xdg-open");
|
|
xdg.arg(url);
|
|
if launch(xdg).is_ok() {
|
|
return Ok(());
|
|
}
|
|
let mut gio = quiet_command("gio");
|
|
gio.args(["open", url]);
|
|
launch(gio).map_err(|_| "Could not open the default browser".into())
|
|
}
|
|
#[cfg(not(linux_desktop))]
|
|
webbrowser::open(url).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod dispatch_tests {
|
|
use super::*;
|
|
use crate::mail_render_projection::open_email_link;
|
|
use std::sync::Mutex;
|
|
|
|
#[test]
|
|
fn mail_links_preserve_allowed_addresses_and_reject_other_schemes() {
|
|
let addresses = Arc::new(Mutex::new(Vec::new()));
|
|
let recorded = addresses.clone();
|
|
let opener = ExternalUrlOpener::new(move |url| {
|
|
recorded.lock().unwrap().push(url.to_owned());
|
|
Ok(())
|
|
});
|
|
let allowed = [
|
|
"http://example.com/path?q=1#part",
|
|
"https://example.com/a%20b?x=1&y=2",
|
|
"HTTPS://example.com/",
|
|
"mailto:person@example.com?subject=Hello%20there",
|
|
"tel:+123456789",
|
|
];
|
|
for url in allowed {
|
|
open_email_link(&opener, &format!(" {url}\n")).unwrap();
|
|
}
|
|
for url in [
|
|
"",
|
|
"example.com",
|
|
"/relative/path",
|
|
"#fragment",
|
|
"--help",
|
|
"javascript:alert(1)",
|
|
"data:text/html,hello",
|
|
"file:///tmp/message.html",
|
|
"intent://example.com",
|
|
] {
|
|
assert!(open_email_link(&opener, url).is_err(), "{url}");
|
|
}
|
|
assert_eq!(*addresses.lock().unwrap(), allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn authorization_validates_before_dispatch_for_default_and_custom_openers() {
|
|
let custom = ExternalUrlOpener::new(|_| panic!("invalid authorization reached the host"));
|
|
for opener in [ExternalUrlOpener::default(), custom] {
|
|
for url in [
|
|
"http://example.com/",
|
|
"file:///tmp/test",
|
|
"mailto:person@example.com",
|
|
"javascript:alert(1)",
|
|
"--help",
|
|
"https://",
|
|
"https://[invalid]/",
|
|
"/relative/path",
|
|
] {
|
|
assert!(opener.open_authorization(url).is_err(), "{url}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_sign_in_session_takes_authorization_but_not_mail_links() {
|
|
let links = Arc::new(Mutex::new(Vec::new()));
|
|
let sessions = Arc::new(Mutex::new(Vec::new()));
|
|
let (recorded_link, recorded_session) = (links.clone(), sessions.clone());
|
|
let opener = ExternalUrlOpener::new(move |url| {
|
|
recorded_link.lock().unwrap().push(url.to_owned());
|
|
Ok(())
|
|
})
|
|
.with_authorization(move |url| {
|
|
recorded_session.lock().unwrap().push(url.to_owned());
|
|
Ok(())
|
|
});
|
|
open_email_link(&opener, "https://example.com/mail").unwrap();
|
|
opener
|
|
.open_authorization("https://example.com/sign-in?state=s")
|
|
.unwrap();
|
|
// Addresses are still checked before the session sees them.
|
|
assert!(opener.open_authorization("http://example.com/").is_err());
|
|
assert_eq!(*links.lock().unwrap(), ["https://example.com/mail"]);
|
|
assert_eq!(
|
|
*sessions.lock().unwrap(),
|
|
["https://example.com/sign-in?state=s"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn authorization_and_mail_share_the_host_across_threads() {
|
|
let addresses = Arc::new(Mutex::new(Vec::new()));
|
|
let recorded = addresses.clone();
|
|
let opener = ExternalUrlOpener::new(move |url| {
|
|
recorded.lock().unwrap().push(url.to_owned());
|
|
Ok(())
|
|
});
|
|
open_email_link(&opener, "https://example.com/mail").unwrap();
|
|
let worker_opener = opener.clone();
|
|
std::thread::spawn(move || {
|
|
worker_opener
|
|
.open_authorization("HTTPS://EXAMPLE.COM/sign-in?state=a%2Bb&code_challenge=c")
|
|
})
|
|
.join()
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
*addresses.lock().unwrap(),
|
|
[
|
|
"https://example.com/mail",
|
|
"https://example.com/sign-in?state=a%2Bb&code_challenge=c",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn host_errors_propagate_without_fallback_or_global_registration() {
|
|
let first = ExternalUrlOpener::new(|_| Err("first host unavailable".into()));
|
|
let second = ExternalUrlOpener::new(|_| Err("second host unavailable".into()));
|
|
for (opener, expected) in [
|
|
(first, "first host unavailable"),
|
|
(second, "second host unavailable"),
|
|
] {
|
|
assert_eq!(
|
|
open_email_link(&opener, "https://example.com/"),
|
|
Err(expected.to_owned())
|
|
);
|
|
assert_eq!(
|
|
opener.open_authorization("https://example.com/"),
|
|
Err(expected.to_owned())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, linux_desktop))]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn detects_launcher_failure() {
|
|
let mut command = Command::new("sh");
|
|
command.args(["-c", "exit 3"]);
|
|
assert!(launch(command).is_err());
|
|
}
|
|
#[test]
|
|
fn detects_launcher_success() {
|
|
let mut command = Command::new("sh");
|
|
command.args(["-c", "exit 0"]);
|
|
assert!(launch(command).is_ok());
|
|
}
|
|
#[test]
|
|
fn foreground_browser_does_not_hold_up_authorization() {
|
|
let mut command = Command::new("sh");
|
|
command.args(["-c", "sleep 2"]);
|
|
let start = std::time::Instant::now();
|
|
assert!(launch(command).is_ok());
|
|
assert!(start.elapsed() < std::time::Duration::from_millis(1800));
|
|
}
|
|
}
|