feat: open mailto links from other apps

This commit is contained in:
Tomás Romero committed 2026-11-05 04:40:34 +01:00
1 parent 5c99506e82
commit d6c9fdf6c7
23 files changed
+4007 -3063

No files matched your search

+2 -2
View File
@@ -172,8 +172,8 @@ slint = { version = "1.18.1", default-features = false, features = ["backend-win
# These AppKit bindings are already in the build.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSAppleEventManager", "NSData", "NSError", "NSGeometry", "NSString", "NSUserDefaults"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSRunningApplication", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSAppleEventManager", "NSData", "NSError", "NSGeometry", "NSNotification", "NSString", "NSUserDefaults"] }
# Registers the bundled sign-in agent with Login Items (macOS 13+).
objc2-service-management = { version = "0.3", default-features = false, features = ["std", "objc2", "objc2-foundation", "SMAppService"] }
+64
View File
@@ -3341,6 +3341,25 @@ impl Core {
Ok(detail)
}
/// The stored message of `account_id` with this RFC Message-ID (with or
/// without angle brackets), so a mailto link's In-Reply-To can thread the
/// reply it starts.
pub async fn message_by_header_id(
&self,
account_id: i64,
message_id: &str,
) -> Result<Option<i64>> {
let message_id = message_id.trim().trim_matches(['<', '>']).to_owned();
if message_id.is_empty() {
return Ok(None);
}
self.db
.read(move |conn| {
Ok(repo::messages::by_message_id(conn, account_id, &message_id)?.map(|row| row.id))
})
.await
}
/// Newest message of `thread_id` that can be unsubscribed from, or None
/// when the thread genuinely offers no list unsubscribe.
///
@@ -10375,6 +10394,51 @@ mod startup_connection_tests {
}
}
#[cfg(test)]
mod message_by_header_id_tests {
use super::*;
#[tokio::test]
async fn finds_the_account_message_with_or_without_brackets() {
let temp = tempfile::tempdir().unwrap();
let core = Core::start_mail_ui(Paths::for_tests(temp.path()))
.await
.unwrap();
let id = core
.db
.write(|conn| {
db::testutil::seed_account(conn);
let (_, id) = db::testutil::seed_message(conn, "list@test.dev", "Topic", false);
conn.execute(
"UPDATE messages SET message_id = 'abc.123@mail.test' WHERE id = ?1",
[id],
)?;
Ok(id)
})
.await
.unwrap();
for header in [
"<abc.123@mail.test>",
"abc.123@mail.test",
" <abc.123@mail.test> ",
] {
assert_eq!(
core.message_by_header_id(1, header).await.unwrap(),
Some(id)
);
}
assert_eq!(
core.message_by_header_id(2, "<abc.123@mail.test>")
.await
.unwrap(),
None,
"another account's message must not be found"
);
assert_eq!(core.message_by_header_id(1, "<>").await.unwrap(), None);
}
}
#[cfg(test)]
mod draft_staging_tests {
use super::*;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+308 -290
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -386,6 +386,8 @@ pub(super) fn refresh_connected_accounts(app: &AppWindow, state: &Rc<RefCell<Inb
app.global::<AccountMailPreferences>()
.invoke_context_changed();
app.global::<FilesUi>().invoke_context_changed();
// A link that waited for an account to send from can open now.
mailto_links::open_pending_soon(app);
}
/// Show the mail kept on this device, or that there is none.
+11
View File
@@ -2018,6 +2018,17 @@ impl CoreMailSource {
.map_err(|error| error.to_string())
}
pub async fn message_by_header_id(
&self,
account_id: i64,
message_id: &str,
) -> Result<Option<i64>, String> {
self.core
.message_by_header_id(account_id, message_id)
.await
.map_err(|error| error.to_string())
}
pub async fn save_new_draft(&self, message: ComposeMessage<'_>) -> Result<i64, String> {
self.save_draft(compose_args(message, false)?).await
}
+339
View File
@@ -0,0 +1,339 @@
//! Opening mailto links, whether clicked in a message or handed over by the
//! system when Flectar Mail is the default email app.
//!
//! Links wait until the mail store is ready and an account can send, then
//! each opens the composer pre-filled for the user to review; nothing is sent
//! on a link's behalf. The composer holds one message, so a link never
//! replaces what the user is writing: a blank composer is reused, anything
//! else is saved to Drafts first, where other clients open another window.
use super::*;
use flectar_mail_core::mailto::{self, Mailbox, Mailto};
use std::collections::VecDeque;
#[cfg(target_os = "macos")]
mod macos;
/// More mail links than this in one launch are ignored.
#[cfg(desktop)]
pub(crate) const MAX_LAUNCH_LINKS: usize = 16;
/// The option the Windows registration puts before the link it passes:
/// `flectar-mail.exe --mailto "%1"`.
#[cfg(desktop)]
pub(crate) const MAILTO_OPTION: &str = "--mailto";
/// The mail links a launch was opened with, or `None` for a launch that is
/// not about mail links, whose options then apply as usual.
///
/// - `--mailto <link>` must be the whole command line, as Thunderbird
/// requires of its `-osint` launches: a link that broke out of its quotes
/// could otherwise add options such as `--background`. Anything else after
/// `--mailto` opens nothing.
/// - Desktop entries pass each link as its own argument (`%U`), so every
/// `mailto:` argument is a link and other arguments are ignored.
#[cfg(desktop)]
pub(crate) fn launch_links(
arguments: impl IntoIterator<Item = std::ffi::OsString>,
) -> Option<Vec<String>> {
let arguments = arguments.into_iter().collect::<Vec<_>>();
if arguments.iter().any(|argument| argument == MAILTO_OPTION) {
return Some(match arguments.as_slice() {
[option, link] if option == MAILTO_OPTION => link
.to_str()
.filter(|link| mailto::is_mailto(link))
.map(|link| vec![link.to_owned()])
.unwrap_or_default(),
_ => Vec::new(),
});
}
let links = arguments
.iter()
.filter_map(|argument| argument.to_str())
.filter(|argument| mailto::is_mailto(argument))
.take(MAX_LAUNCH_LINKS)
.map(str::to_owned)
.collect::<Vec<_>>();
(!links.is_empty()).then_some(links)
}
/// What opening a link needs from the rest of the application.
#[derive(Clone)]
pub(super) struct Composer {
pub(super) state: Rc<RefCell<InboxState>>,
pub(super) runtime: Rc<tokio::runtime::Runtime>,
pub(super) intent: Rc<RefCell<ComposeIntent>>,
}
#[derive(Default)]
struct Links {
pending: VecDeque<Mailto>,
composer: Option<Composer>,
scheduled: bool,
}
thread_local! {
static LINKS: RefCell<Links> = RefCell::default();
}
/// Connect the composer. Links received before this wait for it.
pub(super) fn install(app: &AppWindow, composer: Composer) {
LINKS.with(|links| links.borrow_mut().composer = Some(composer));
open_pending_soon(app);
}
/// Open the mail links macOS hands to the application. They arrive as Apple
/// Events rather than on the command line, also for the launch itself, and
/// bring the window back like any other launch.
#[cfg(target_os = "macos")]
pub(super) fn receive_from_macos(app: &AppWindow) {
let app = app.as_weak();
macos::on_open_url(move |url| {
if !mailto::is_mailto(&url) {
return;
}
let Some(app) = app.upgrade() else {
return;
};
window_controller::activate(&app, None);
open(&app, [url]);
});
}
/// Open mailto links in the composer as soon as it can take them.
pub(super) fn open<S: AsRef<str>>(app: &AppWindow, uris: impl IntoIterator<Item = S>) {
let mut unreadable = false;
LINKS.with(|links| {
let mut links = links.borrow_mut();
for uri in uris {
match mailto::parse(uri.as_ref()) {
Some(message) => links.pending.push_back(message),
None => unreadable = true,
}
}
});
if unreadable {
app.set_render_status(UiMessage::plain("This email link is too long to open."));
}
open_pending_soon(app);
}
/// Try the waiting links again on the next turn of the event loop: after
/// startup, when accounts change and when the composer closes. Deferring keeps
/// this out of whatever borrow of the application state the caller holds.
pub(super) fn open_pending_soon(app: &AppWindow) {
let idle = LINKS.with(|links| {
let mut links = links.borrow_mut();
let idle = !links.pending.is_empty() && !links.scheduled;
links.scheduled |= idle;
idle
});
if !idle {
return;
}
let app = app.as_weak();
Timer::single_shot(Duration::ZERO, move || {
LINKS.with(|links| links.borrow_mut().scheduled = false);
if let Some(app) = app.upgrade() {
open_pending(&app);
}
});
}
fn open_pending(app: &AppWindow) {
while let Some((message, composer)) = LINKS.with(|links| {
let links = links.borrow();
Some((links.pending.front()?.clone(), links.composer.clone()?))
}) {
if !app.get_startup_ready() {
return;
}
if composer.state.borrow().connected_accounts.is_empty() {
app.set_sync_status(UiMessage::plain("Add an account to send mail."));
return;
}
let Some(saved_previous) = make_room(app) else {
return;
};
if !compose(app, &composer, &message, saved_previous) {
return;
}
LINKS.with(|links| links.borrow_mut().pending.pop_front());
}
}
/// Close a blank composer, or save what the user is writing to Drafts.
/// `Some(true)` when a message was saved; `None` when the composer has to
/// stay, because it is sending or could not be saved (its notice says why).
fn make_room(app: &AppWindow) -> Option<bool> {
if !app.get_compose_open() {
return Some(false);
}
if app.get_compose_sending() {
return None;
}
if composer_is_blank(app) {
app.invoke_close_compose();
return (!app.get_compose_open()).then_some(false);
}
app.invoke_save_compose(
app.get_compose_account_id(),
app.get_compose_to(),
app.get_compose_cc(),
app.get_compose_bcc(),
app.get_compose_subject(),
app.get_compose_body(),
false,
);
if app.get_compose_open() {
app.set_sync_status(UiMessage::plain(
"The email link will open when you close this message.",
));
return None;
}
Some(true)
}
fn composer_is_blank(app: &AppWindow) -> bool {
[
app.get_compose_to(),
app.get_compose_cc(),
app.get_compose_bcc(),
app.get_compose_subject(),
app.get_compose_body(),
]
.iter()
.all(|text| text.trim().is_empty())
&& app.get_compose_attachments().row_count() == 0
}
fn compose(app: &AppWindow, composer: &Composer, message: &Mailto, saved_previous: bool) -> bool {
app.invoke_open_compose();
if !app.get_compose_open() {
return false;
}
app.set_compose_to(recipient_field(&message.to).into());
app.set_compose_cc(recipient_field(&message.cc).into());
app.set_compose_bcc(recipient_field(&message.bcc).into());
if let Some(subject) = &message.subject {
app.set_compose_subject(subject.into());
}
if let Some(body) = &message.body {
app.invoke_edit_compose_body(body.into(), 0, 0, "".into());
}
if let Some(header_id) = &message.in_reply_to {
thread_reply(app, composer, header_id);
}
if !message.attachment_requests.is_empty() {
app.set_compose_notice(UiMessage::detail(
"Files from links are never attached for you. Attach them yourself if you trust them: {}",
message.attachment_requests.join(", "),
));
app.set_compose_notice_is_error(false);
} else if saved_previous {
app.set_compose_notice(UiMessage::plain(
"The message you were writing was saved to Drafts.",
));
app.set_compose_notice_is_error(false);
}
true
}
/// Make the message a reply when the account it is written from has the
/// message the link replies to, so it threads like any other reply. The
/// sending account is never changed for a link.
fn thread_reply(app: &AppWindow, composer: &Composer, header_id: &str) {
let Some(core) = composer.state.borrow().core.clone() else {
return;
};
let account_id = i64::from(app.get_compose_account_id());
let parent = composer
.runtime
.block_on(core.message_by_header_id(account_id, header_id));
if let Ok(Some(parent)) = parent {
let mut intent = composer.intent.borrow_mut();
intent.mode = "reply".to_owned();
intent.in_reply_to_message_id = Some(parent);
app.set_compose_mode("reply".into());
}
}
/// The composer's recipient syntax: entries separated by commas, each
/// `Name <address>` or a bare address. Names lose the characters that syntax
/// gives meaning to.
fn recipient_field(mailboxes: &[Mailbox]) -> String {
mailboxes
.iter()
.map(|mailbox| {
let name = mailbox.name.as_deref().map(|name| {
name.chars()
.filter(|c| !matches!(c, ',' | ';' | '<' | '>' | '"'))
.collect::<String>()
});
match name.as_deref().map(str::trim) {
Some(name) if !name.is_empty() => format!("{name} <{}>", mailbox.email),
_ => mailbox.email.clone(),
}
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(desktop)]
#[test]
fn launch_links_come_only_from_mail_link_command_lines() {
let links = |arguments: &[&str]| {
launch_links(
arguments
.iter()
.map(|argument| std::ffi::OsString::from(*argument)),
)
};
assert_eq!(links(&[]), None);
assert_eq!(links(&["--background"]), None);
assert_eq!(
links(&["--mailto", "mailto:a@example.com?subject=x"]),
Some(vec!["mailto:a@example.com?subject=x".into()])
);
assert_eq!(
links(&["MAILTO:a@example.com", "--verbose", "mailto:"]),
Some(vec!["MAILTO:a@example.com".into(), "mailto:".into()])
);
// A link that escaped its quotes cannot add options or open anything.
for arguments in [
&["--mailto", "mailto:a@example.com", "--background", ""][..],
&["--mailto", "mailto:a@example.com\\", "--background"],
&["--background", "--mailto", "mailto:a@example.com"],
&["--mailto", "https://example.com"],
&["--mailto"],
] {
assert_eq!(links(arguments), Some(Vec::new()), "{arguments:?}");
}
let many = vec!["mailto:a@example.com"; MAX_LAUNCH_LINKS + 1];
assert_eq!(links(&many).unwrap().len(), MAX_LAUNCH_LINKS);
}
fn mailbox(name: Option<&str>, email: &str) -> Mailbox {
Mailbox {
name: name.map(str::to_owned),
email: email.to_owned(),
}
}
#[test]
fn recipient_field_round_trips_through_the_composer_syntax() {
let field = recipient_field(&[
mailbox(Some("Doe, Jane"), "jane@example.com"),
mailbox(None, "bob+list@example.com"),
mailbox(Some(" <\"> "), "x@example.com"),
]);
assert_eq!(
field,
"Doe Jane <jane@example.com>, bob+list@example.com, x@example.com"
);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Receive the links macOS opens with Flectar Mail.
//!
//! macOS passes a URL to the application as a get-URL Apple Event, never on
//! the command line, also when the link is what launched it. AppKit turns the
//! event into `application:openURLs:`, which winit's application delegate
//! does not implement, so it is handled here.
//!
//! The handler has to be registered when AppKit finishes launching: AppKit
//! installs its own handlers first, replacing any registered earlier, and
//! delivers the event that launched the application right after. Apple's
//! scripting guide names `applicationWillFinishLaunching:` for this, and
//! later registration would lose the launch link (Zed hit exactly that).
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSApplicationWillFinishLaunchingNotification, NSRunningApplication};
use objc2_foundation::{NSAppleEventManager, NSNotificationCenter, NSObject, NSString};
use std::cell::{OnceCell, RefCell};
/// `kInternetEventClass`, `kAEGetURL` and `keyDirectObject`.
const INTERNET_EVENT_CLASS: u32 = u32::from_be_bytes(*b"GURL");
const GET_URL: u32 = u32::from_be_bytes(*b"GURL");
const DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----");
/// Receives each URL on the main thread.
type UrlCallback = Box<dyn Fn(String)>;
thread_local! {
/// Neither NSAppleEventManager nor NSNotificationCenter retains it.
static HANDLER: OnceCell<Retained<UrlHandler>> = const { OnceCell::new() };
static CALLBACK: RefCell<Option<UrlCallback>> = const { RefCell::new(None) };
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "FlectarMailUrlHandler"]
struct UrlHandler;
impl UrlHandler {
#[unsafe(method(applicationWillFinishLaunching:))]
fn will_finish_launching(&self, _notification: &AnyObject) {
register(self);
}
#[unsafe(method(handleGetURL:withReplyEvent:))]
fn handle_get_url(&self, event: &AnyObject, _reply: &AnyObject) {
// SAFETY: `event` is the NSAppleEventDescriptor of a get-URL
// event; both selectors return a retained object or nil.
// `paramDescriptorForKeyword:` takes an AEKeyword (UInt32).
let url: Option<Retained<NSString>> = unsafe {
let parameter: Option<Retained<AnyObject>> =
msg_send![event, paramDescriptorForKeyword: DIRECT_OBJECT];
match parameter {
Some(parameter) => msg_send![&parameter, stringValue],
None => None,
}
};
let Some(url) = url else {
return;
};
CALLBACK.with(|callback| {
if let Some(callback) = callback.borrow().as_ref() {
callback(url.to_string());
}
});
}
}
);
/// Call `callback` with each URL macOS opens with the application. Call it
/// before the event loop starts, so the link a launch was started for
/// arrives too.
pub(super) fn on_open_url(callback: impl Fn(String) + 'static) {
let Some(main_thread) = MainThreadMarker::new() else {
return;
};
CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback)));
HANDLER.with(|handler| {
let handler = handler.get_or_init(|| {
// SAFETY: `init` is NSObject's designated initializer.
unsafe { msg_send![UrlHandler::alloc(main_thread), init] }
});
// Registering now would be overwritten by AppKit's own handler,
// unless launching is already over.
if NSRunningApplication::currentApplication().isFinishedLaunching() {
register(handler);
return;
}
// SAFETY: the handler implements the selector with the signature a
// notification observer needs, and HANDLER keeps it alive for the
// life of the thread.
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
handler,
sel!(applicationWillFinishLaunching:),
Some(NSApplicationWillFinishLaunchingNotification),
None,
);
}
});
}
fn register(handler: &UrlHandler) {
let manager = NSAppleEventManager::sharedAppleEventManager();
// SAFETY: the handler implements the selector with the signature
// NSAppleEventManager calls, and HANDLER keeps it alive.
unsafe {
let _: () = msg_send![
&manager,
setEventHandler: handler,
andSelector: sel!(handleGetURL:withReplyEvent:),
forEventClass: INTERNET_EVENT_CLASS,
andEventID: GET_URL,
];
}
}
+30 -6
View File
@@ -32,6 +32,7 @@ mod mail_scope;
mod mail_setup;
mod mail_view_model;
mod mail_work;
mod mailto_links;
mod message_appearance;
mod notifications;
pub use notifications::{NewMailNotification, NotificationPublisher};
@@ -1588,16 +1589,20 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
.build()?,
);
#[cfg(desktop)]
let background_launch = autostart::launched_in_background();
let launch_links = mailto_links::launch_links(std::env::args_os().skip(1));
// A mail link launch always shows the window, whatever else it says.
#[cfg(desktop)]
let background_launch = launch_links.is_none() && autostart::launched_in_background();
#[cfg(desktop)]
let mut instance = {
use single_instance::{Acquired, Request};
use single_instance::{Acquired, Activation, Request};
let request = if background_launch {
Request::Nothing
} else {
Request::Show {
Request::Show(Activation {
activation_token: window_chrome::launch_activation_token(),
}
links: launch_links.clone().unwrap_or_default(),
})
};
match single_instance::acquire(&runtime, &platform.paths.data_dir, request) {
Ok(Acquired::Primary(primary)) => Some(primary),
@@ -1796,12 +1801,16 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
#[cfg(desktop)]
if let Some(instance) = instance.as_mut() {
let app = app.as_weak();
instance.serve(&runtime, move |activation_token| {
instance.serve(&runtime, move |activation| {
let _ = app.upgrade_in_event_loop(move |app| {
window_controller::activate(&app, activation_token);
window_controller::activate(&app, activation.activation_token);
mailto_links::open(&app, activation.links);
});
});
}
// They open once the mail store is ready and the composer connected.
#[cfg(desktop)]
mailto_links::open(&app, launch_links.unwrap_or_default());
// The launcher's token was meant for this window: it lets the desktop
// focus it (Wayland) and ends the launch feedback (X11).
#[cfg(desktop)]
@@ -4367,6 +4376,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
refresh_list_metadata(&app, &startup_state);
app.set_startup_hydrated(true);
app.set_startup_ready(true);
mailto_links::open_pending_soon(&app);
app.invoke_product_changed(app.get_active_view());
app.global::<TabsUi>().invoke_restore_active();
app.set_startup_failed(false);
@@ -6181,6 +6191,17 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
}
});
mailto_links::install(
&app,
mailto_links::Composer {
state: Rc::clone(&state),
runtime: Rc::clone(&runtime),
intent: Rc::clone(&compose_intent),
},
);
#[cfg(target_os = "macos")]
mailto_links::receive_from_macos(&app);
let app_weak = app.as_weak();
let state_for_message_compose = Rc::clone(&state);
let runtime_for_message_compose = Rc::clone(&runtime);
@@ -6443,6 +6464,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
&contacts_for_close,
);
*intent_for_close.borrow_mut() = ComposeIntent::default();
mailto_links::open_pending_soon(&app);
}
});
@@ -6947,6 +6969,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
app.set_sync_status(message);
let _ =
refresh_from_source(&app, &state_for_compose, &runtime_for_compose, true, &[]);
mailto_links::open_pending_soon(&app);
}
Err(error) => {
let message = UiMessage::detail("Compose failed: {}", error);
@@ -6997,6 +7020,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
true,
&[],
);
mailto_links::open_pending_soon(&app);
}
Err(error) => {
let message = UiMessage::detail(
+92 -3
View File
@@ -89,10 +89,13 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
assert_eq!(app.get_render_message().argument, "test host unavailable");
app.invoke_open_email_link("javascript:alert(1)".into());
app.invoke_open_email_link("#fragment".into());
app.invoke_open_email_link("mailto:person@example.com?subject=Hello%20there".into());
assert_eq!(app.get_compose_to(), "person@example.com");
assert_eq!(app.get_compose_subject(), "Hello there");
// Mail links never go to the browser. This one waits for the composer,
// which is connected at the end of the test.
app.invoke_open_email_link(
"mailto:person+list@example.com?subject=Hello+there%21&bcc=hidden@example.com".into(),
);
assert_eq!(opened_urls.lock().unwrap().len(), 1);
assert!(!app.get_compose_open());
let requests = Rc::new(RefCell::new(Vec::new()));
let requests2 = requests.clone();
app.global::<EmailReader>()
@@ -766,9 +769,95 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
&mut directory.borrow_mut()
));
assert!(directory.borrow().contacts.is_empty());
app.set_active_view("mail".into());
mailto_links_open_without_losing_the_draft(&app, &state, &runtime);
app.hide().unwrap();
}
fn mailto_links_open_without_losing_the_draft(
app: &AppWindow,
state: &Rc<RefCell<InboxState>>,
runtime: &Rc<tokio::runtime::Runtime>,
) {
state.borrow_mut().connected_accounts = vec![Account {
id: 1,
email: "me@example.test".into(),
display_name: None,
avatar_url: None,
provider: Provider::Generic,
auth_kind: flectar_mail_core::models::AuthKind::Password,
mail_protocol: MailProtocol::Imap,
sync_state: "idle".into(),
sync_error: None,
can_create_top_level_mailbox: true,
server_keeps_mail_days: None,
}];
app.set_compose_account_id(1);
let weak = app.as_weak();
app.on_open_compose(move || weak.unwrap().set_compose_open(true));
let saved = Rc::new(RefCell::new(Vec::new()));
let weak = app.as_weak();
let recorded = saved.clone();
app.on_save_compose(move |_, to, _, _, subject, _, send| {
assert!(!send, "a link must never send");
recorded
.borrow_mut()
.push((to.to_string(), subject.to_string()));
let app = weak.unwrap();
for clear in [
AppWindow::set_compose_to,
AppWindow::set_compose_cc,
AppWindow::set_compose_bcc,
AppWindow::set_compose_subject,
AppWindow::set_compose_body,
] {
clear(&app, "".into());
}
app.set_compose_open(false);
});
let frame = || {
slint::platform::update_timers_and_animations();
let _ = app.window().size();
};
mailto_links::install(
app,
mailto_links::Composer {
state: Rc::clone(state),
runtime: Rc::clone(runtime),
intent: Rc::new(RefCell::new(ComposeIntent::default())),
},
);
frame();
assert!(app.get_compose_open(), "the waiting link opens once it can");
assert_eq!(app.get_compose_to(), "person+list@example.com");
assert_eq!(app.get_compose_subject(), "Hello+there!");
assert_eq!(app.get_compose_bcc(), "hidden@example.com");
app.set_compose_subject("My own subject".into());
// The reader's link handler was replaced by a recorder above; open the
// link the way it does.
mailto_links::open(app, ["mailto:second@example.com?attach=/etc/passwd"]);
frame();
assert_eq!(
*saved.borrow(),
[(
"person+list@example.com".to_owned(),
"My own subject".to_owned()
)],
"what the user wrote is saved to Drafts before the next link opens"
);
assert!(app.get_compose_open());
assert_eq!(app.get_compose_to(), "second@example.com");
assert_eq!(app.get_compose_bcc(), "");
assert_eq!(
app.get_compose_message().key,
"Files from links are never attached for you. Attach them yourself if you trust them: {}"
);
assert_eq!(app.get_compose_message().argument, "/etc/passwd");
assert!(app.get_compose_attachments().row_count() == 0);
}
/// The desktop mail view at scale 2, drawn by Slint's software renderer like
/// CPU mode. That renderer panics when it paints a rectangle reaching past
/// ±32767 physical pixels (#35), so reader surfaces sized by a long message
+2 -20
View File
@@ -115,26 +115,8 @@ pub(super) fn register_renderer_input_callbacks(
}
return;
}
if let Ok(parsed) = url::Url::parse(&url)
&& parsed.scheme() == "mailto"
{
app.invoke_open_compose();
// Form decoding also handles UTF-8 and percent escapes in addresses.
let address = percent_encoding::percent_decode_str(parsed.path())
.decode_utf8_lossy()
.into_owned();
app.set_compose_to(address.into());
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"subject" => app.set_compose_subject(value.into_owned().into()),
"cc" => app.set_compose_cc(value.into_owned().into()),
"bcc" => app.set_compose_bcc(value.into_owned().into()),
"body" => {
app.invoke_edit_compose_body(value.into_owned().into(), 0, 0, "".into())
}
_ => {}
}
}
if flectar_mail_core::mailto::is_mailto(&url) {
crate::mailto_links::open(&app, [url.as_str()]);
return;
}
if let Err(error) = open_email_link(&opener, &url) {
+147 -42
View File
@@ -3,27 +3,31 @@
//! The first process owns the profile and listens on a private local endpoint:
//! a Unix socket guarded by an advisory lock, or a named pipe whose first
//! instance is exclusive on Windows. A later launch asks that process to show
//! its window and exits, so opening Flectar Mail from a launcher while it runs
//! in the tray restores the existing window instead of starting a second sync
//! engine on the same database. A sign-in launch that finds the application
//! already running exits without disturbing it.
//! its window, passing on any mail links it was opened with, and exits. So
//! opening Flectar Mail from a launcher or a `mailto:` link while it runs in
//! the tray uses the existing window instead of starting a second sync engine
//! on the same database. A sign-in launch that finds the application already
//! running exits without disturbing it.
//!
//! Both the lock and the pipe are released by the kernel when the owner exits,
//! even after a crash, so a stale endpoint never blocks a later launch.
use crate::mailto_links::MAX_LAUNCH_LINKS;
use flectar_mail_core::mailto;
use sha2::{Digest, Sha256};
use std::{io, path::Path, sync::Arc, time::Duration};
use tokio::{io::AsyncReadExt, runtime::Runtime, task::JoinHandle};
/// The only request: show and focus the main window, optionally followed by
/// The only request: show and focus the main window. The first line may add
/// the launcher's activation token so the compositor lets the window take
/// focus.
/// focus; each further line is a mail link to open.
const SHOW: &str = "show";
/// Activation tokens are short opaque strings; bound what we accept.
const MAX_TOKEN: usize = 512;
/// Longer than any valid request, so a misbehaving client cannot make the
/// owner buffer more.
const MAX_REQUEST: u64 = (SHOW.len() + 1 + MAX_TOKEN + 1) as u64;
const MAX_REQUEST: u64 =
(SHOW.len() + 1 + MAX_TOKEN + 1 + MAX_LAUNCH_LINKS * (mailto::MAX_URI_BYTES + 1)) as u64;
/// How long the owner waits for a connected client to send its request.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a later launch waits for a starting owner to accept requests.
@@ -43,14 +47,23 @@ pub(crate) enum Acquired {
/// What a launch should ask of an already running instance.
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum Request {
/// Show the window, passing on the launcher's activation token.
Show { activation_token: Option<String> },
/// Show the window and open the mail links.
Show(Activation),
/// A sign-in launch: leave a running instance exactly as it is.
Nothing,
}
/// Runs on a runtime worker thread with the forwarded activation token.
type ShowCallback = Arc<dyn Fn(Option<String>) + Send + Sync>;
/// A launch handed to the running instance.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Activation {
/// The launcher's token, for the compositor to let the window take focus.
pub(crate) activation_token: Option<String>,
/// `mailto:` links the launch was opened with.
pub(crate) links: Vec<String>,
}
/// Runs on a runtime worker thread with each forwarded launch.
type ShowCallback = Arc<dyn Fn(Activation) + Send + Sync>;
pub(crate) struct Primary {
endpoint: imp::Endpoint,
@@ -58,13 +71,13 @@ pub(crate) struct Primary {
}
impl Primary {
/// Start answering later launches. `show` receives the forwarded
/// activation token on a runtime worker thread and must forward to the
/// UI thread itself.
/// Start answering later launches. `show` receives each forwarded
/// launch on a runtime worker thread and must forward to the UI thread
/// itself.
pub(crate) fn serve(
&mut self,
runtime: &Runtime,
show: impl Fn(Option<String>) + Send + Sync + 'static,
show: impl Fn(Activation) + Send + Sync + 'static,
) {
let show: ShowCallback = Arc::new(show);
let _context = runtime.enter();
@@ -102,10 +115,10 @@ pub(crate) fn acquire(
server: None,
}));
}
let Request::Show { activation_token } = &request else {
let Request::Show(activation) = &request else {
return Ok(Acquired::Forwarded);
};
let message = encode_show(activation_token.as_deref());
let message = encode_show(activation);
match runtime.block_on(imp::forward(data_dir, &message)) {
Ok(()) => return Ok(Acquired::Forwarded),
// The owner may have exited after the claim failed; claim again.
@@ -139,26 +152,58 @@ fn valid_token(token: &str) -> bool {
(1..=MAX_TOKEN).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_graphic())
}
fn encode_show(activation_token: Option<&str>) -> Vec<u8> {
match activation_token.filter(|token| valid_token(token)) {
Some(token) => format!("{SHOW} {token}\n").into_bytes(),
None => format!("{SHOW}\n").into_bytes(),
fn encode_show(activation: &Activation) -> Vec<u8> {
let mut request = match activation
.activation_token
.as_deref()
.filter(|token| valid_token(token))
{
Some(token) => format!("{SHOW} {token}\n"),
None => format!("{SHOW}\n"),
};
// Line breaks mean the same percent-encoded inside a mailto link, so a
// link always fits on one line. Links the owner would refuse are dropped
// here rather than failing the whole launch.
let links = activation
.links
.iter()
.map(|link| link.replace('\r', "%0D").replace('\n', "%0A"))
.filter(|link| valid_link(link))
.take(MAX_LAUNCH_LINKS);
for link in links {
request.push_str(&link);
request.push('\n');
}
request.into_bytes()
}
/// `Some(token)` for a well-formed show request; anything else is ignored.
fn parse_show(request: &[u8]) -> Option<Option<String>> {
let line = std::str::from_utf8(request).ok()?.strip_suffix('\n')?;
match line.split_once(' ') {
None => (line == SHOW).then_some(None),
Some((SHOW, token)) => valid_token(token).then(|| Some(token.to_owned())),
Some(_) => None,
/// The launch for a well-formed show request; anything else is ignored.
fn parse_show(request: &[u8]) -> Option<Activation> {
let request = std::str::from_utf8(request).ok()?.strip_suffix('\n')?;
let mut lines = request.split('\n');
let first = lines.next()?;
let activation_token = match first.split_once(' ') {
None if first == SHOW => None,
Some((SHOW, token)) if valid_token(token) => Some(token.to_owned()),
_ => return None,
};
let links = lines.map(str::to_owned).collect::<Vec<_>>();
if links.len() > MAX_LAUNCH_LINKS || !links.iter().all(|link| valid_link(link)) {
return None;
}
Some(Activation {
activation_token,
links,
})
}
fn handle_request(request: Option<Vec<u8>>, show: &(dyn Fn(Option<String>) + Send + Sync)) {
if let Some(activation_token) = request.as_deref().and_then(parse_show) {
show(activation_token);
fn valid_link(link: &str) -> bool {
mailto::is_mailto(link) && link.len() <= mailto::MAX_URI_BYTES && !link.contains(['\r', '\n'])
}
fn handle_request(request: Option<Vec<u8>>, show: &(dyn Fn(Activation) + Send + Sync)) {
if let Some(activation) = request.as_deref().and_then(parse_show) {
show(activation);
}
}
@@ -421,12 +466,17 @@ mod tests {
.unwrap()
}
fn show(activation_token: Option<&str>) -> Request {
Request::Show {
fn activation(activation_token: Option<&str>, links: &[&str]) -> Activation {
Activation {
activation_token: activation_token.map(str::to_owned),
links: links.iter().map(|link| (*link).to_owned()).collect(),
}
}
fn show(activation_token: Option<&str>) -> Request {
Request::Show(activation(activation_token, &[]))
}
#[test]
fn later_launches_reach_the_first_process_and_leave_it_running() {
let runtime = runtime();
@@ -437,15 +487,32 @@ mod tests {
panic!("the first launch must own the profile");
};
let (shown_tx, shown_rx) = std::sync::mpsc::channel();
primary.serve(&runtime, move |token| shown_tx.send(token).unwrap());
primary.serve(&runtime, move |activation| {
shown_tx.send(activation).unwrap()
});
assert!(matches!(
acquire(&runtime, directory.path(), show(Some("launcher-token_1"))).unwrap(),
Acquired::Forwarded
));
assert_eq!(
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap().as_deref(),
Some("launcher-token_1")
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
activation(Some("launcher-token_1"), &[])
);
// A mail link opened while the application runs reaches it whole.
let link = format!(
"mailto:a+b@example.com?subject=Hi&body={}%0D%0Aend",
"x".repeat(100_000)
);
let request = Request::Show(activation(None, &[&link, "mailto:"]));
assert!(matches!(
acquire(&runtime, directory.path(), request).unwrap(),
Acquired::Forwarded
));
assert_eq!(
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
activation(None, &[&link, "mailto:"])
);
assert!(matches!(
@@ -482,14 +549,39 @@ mod tests {
#[test]
fn only_well_formed_show_requests_are_accepted() {
assert_eq!(parse_show(&encode_show(None)), Some(None));
let round_trip = |activation: Activation| parse_show(&encode_show(&activation));
assert_eq!(
parse_show(&encode_show(Some("abc:123"))),
Some(Some("abc:123".to_owned()))
round_trip(activation(None, &[])),
Some(activation(None, &[]))
);
assert_eq!(
round_trip(activation(Some("abc:123"), &["mailto:a@example.com"])),
Some(activation(Some("abc:123"), &["mailto:a@example.com"]))
);
// Invalid tokens are dropped by the sender rather than the request.
assert_eq!(parse_show(&encode_show(Some("has space"))), Some(None));
assert_eq!(parse_show(&encode_show(Some(&"x".repeat(MAX_TOKEN + 1)))), Some(None));
assert_eq!(
round_trip(activation(Some("has space"), &[])),
Some(activation(None, &[]))
);
assert_eq!(
round_trip(activation(Some(&"x".repeat(MAX_TOKEN + 1)), &[])),
Some(activation(None, &[]))
);
// So are links the owner would refuse, and raw line breaks are
// encoded the way the link means them.
let too_long = format!("mailto:?body={}", "x".repeat(mailto::MAX_URI_BYTES));
assert_eq!(
round_trip(activation(
None,
&["https://example.com", &too_long, "mailto:?body=a\r\nb"]
)),
Some(activation(None, &["mailto:?body=a%0D%0Ab"]))
);
let many = vec!["mailto:a@example.com"; MAX_LAUNCH_LINKS + 3];
assert_eq!(
round_trip(activation(None, &many)).unwrap().links.len(),
MAX_LAUNCH_LINKS
);
for request in [
&b"quit\n"[..],
@@ -499,9 +591,22 @@ mod tests {
b"show a b\n",
b"shows\n",
b"show \xff\n",
b"show\nhttps://example.com\n",
b"show\nmailto:a@example.com",
b"show\nmailto:a@example.com\n\n",
b"mailto:a@example.com\n",
] {
assert_eq!(parse_show(request), None, "{request:?}");
}
assert!(MAX_REQUEST as usize >= encode_show(Some(&"x".repeat(MAX_TOKEN))).len());
let largest = activation(
Some(&"x".repeat(MAX_TOKEN)),
&vec![
&*format!("mailto:?body={}", "x".repeat(mailto::MAX_URI_BYTES - 13));
MAX_LAUNCH_LINKS
],
);
assert_eq!(largest.links[0].len(), mailto::MAX_URI_BYTES);
assert!(MAX_REQUEST as usize >= encode_show(&largest).len());
assert_eq!(round_trip(largest.clone()), Some(largest));
}
}
+4
View File
@@ -302,6 +302,10 @@ export global I18n {
ui-message-system(key, argument, second-argument, third-argument) => {
if (key == "Opened {}") { return @tr("Opened {}", argument); }
if (key == "Could not open link: {}") { return @tr("Could not open link: {}", argument); }
if (key == "This email link is too long to open.") { return @tr("This email link is too long to open."); }
if (key == "The email link will open when you close this message.") { return @tr("The email link will open when you close this message."); }
if (key == "The message you were writing was saved to Drafts.") { return @tr("The message you were writing was saved to Drafts."); }
if (key == "Files from links are never attached for you. Attach them yourself if you trust them: {}") { return @tr("Files from links are never attached for you. Attach them yourself if you trust them: {}", argument); }
if (key == "Copied HTML source") { return @tr("Copied HTML source"); }
if (key == "Copied selected text") { return @tr("Copied selected text"); }
if (key == "Copied email text") { return @tr("Copied email text"); }
+4
View File
@@ -295,6 +295,10 @@ export component ComposerRecipients inherits Rectangle {
callback contact-selected(int);
private property <bool> show-cc: root.cc != "";
private property <bool> show-bcc: root.bcc != "";
// Recipients filled in for the user, such as from a mail link, always
// show, even in a row that was toggled off.
changed cc => { if (root.cc != "") { root.show-cc = true; } }
changed bcc => { if (root.bcc != "") { root.show-bcc = true; } }
private property <bool> suggestions-dismissed: false;
// Suggestion rows hold a name and an address that grow with the platform font.
private property <length> suggestion-pitch: 13px + max(18px, suggestion-name-probe.preferred-height)