mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
feat: handle mailto: URLs, and ship a desktop entry claiming the scheme
cce-mail had no desktop entry and no argv handling at all, so it could not be chosen as the Mail default — the settings app offers only installed entries claiming x-scheme-handler/mailto. It now ships one with Exec=cce-mail %u, and a mailto: argument opens the compose dialog prefilled.
Parsing is RFC 6068 by hand (~40 lines) rather than a new url dependency: recipients from the path plus any to= key, subject= and body= percent-decoded on bytes so multi-byte UTF-8 split across %XX survives, '+' kept literal (mailto is not form encoding), header names case-insensitive, unknown ones ignored. cc=/bcc= fold into the To line — the dialog has one recipient field, and merging delivers to everyone where dropping would silently lose them; the merged line is visible before anything is sent. Prefill sets text and edit_buffer both, same as ComposeNew/Reply, since ComposeSend reads whichever side the editing flag selects.
send_smtp now splits To on commas and adds each address as its own .to() — lettre's Mailbox parse takes exactly one address, so a multi-recipient prefill (or a hand-typed list, which failed the same way) previously died at send with Invalid Recipient.
Verified headless: a mailto: with two path addresses, an encoded subject/body and a cc opens compose with all three fields correct; a bare launch still opens the inbox.
Co-Authored-By: Claude <[email protected]>
cce-mail.desktop | 12 +++++
src/main.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 152 insertions(+), 13 deletions(-)
diff --git a/cce-mail.desktop b/cce-mail.desktop
new file mode 100644
index 0000000..c1f7ae1
--- /dev/null
+++ b/cce-mail.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Type=Application
+Name=Mail
+GenericName=Email Client
+Comment=Read and send email
+Exec=cce-mail %u
+Icon=cce-mail
+Terminal=false
+Categories=Network;Email;
+MimeType=x-scheme-handler/mailto;
+StartupNotify=true
+StartupWMClass=cce-mail
diff --git a/src/main.rs b/src/main.rs
index 97286f6..f349e41 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -560,6 +560,79 @@ fn strip_html(html: &str) -> String {
collapsed
}
+/// What a `mailto:` argv prefills into the compose dialog.
+#[derive(Debug, Clone, PartialEq, Default)]
+struct MailtoPrefill {
+ to: String,
+ subject: String,
+ body: String,
+}
+
+/// RFC 6068 percent-decoding. Unlike form encoding, `+` is a literal plus in
+/// a mailto URL — spaces arrive as `%20` — so only `%XX` sequences decode.
+/// Decoding happens on bytes and re-validates as UTF-8, since an encoded
+/// subject/body may hold multi-byte sequences split across several `%XX`.
+fn percent_decode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' {
+ // get() covers a trailing "%" or "%X"; a non-hex pair falls
+ // through and the '%' stays literal, matching lenient browsers.
+ let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok());
+ if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
+ out.push(v);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// Parse a `mailto:` URL (RFC 6068) into compose-dialog prefills.
+///
+/// Recipients come from the path *and* any `to` query key; `cc`/`bcc` are
+/// folded into the To line as well — the dialog has a single recipient field,
+/// and merging delivers to everyone where dropping would silently lose them
+/// (the merged line is visible in the dialog before anything is sent).
+/// Header names are case-insensitive; unknown ones are ignored per spec.
+fn parse_mailto(arg: &str) -> Option<MailtoPrefill> {
+ let rest = arg.strip_prefix("mailto:").or_else(|| arg.strip_prefix("MAILTO:"))?;
+ let (path, query) = match rest.split_once('?') {
+ Some((p, q)) => (p, Some(q)),
+ None => (rest, None),
+ };
+ let mut recipients: Vec<String> = path
+ .split(',')
+ .map(percent_decode)
+ .map(|a| a.trim().to_string())
+ .filter(|a| !a.is_empty())
+ .collect();
+ let mut prefill = MailtoPrefill::default();
+ if let Some(q) = query {
+ for pair in q.split('&') {
+ let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
+ match k.to_ascii_lowercase().as_str() {
+ "to" | "cc" | "bcc" => recipients.extend(
+ percent_decode(v)
+ .split(',')
+ .map(|a| a.trim().to_string())
+ .filter(|a| !a.is_empty()),
+ ),
+ "subject" => prefill.subject = percent_decode(v),
+ "body" => prefill.body = percent_decode(v),
+ _ => {}
+ }
+ }
+ }
+ prefill.to = recipients.join(", ");
+ Some(prefill)
+}
+
const GOOGLE_CLIENT_ID: &str = "946029775684-m4u4mme60a6a0qj3p5m5jvea8d2987o9.apps.googleusercontent.com";
const GOOGLE_CLIENT_SECRET: &str = "GOCSPX-dummysecret";
@@ -1101,21 +1174,26 @@ fn send_smtp(mut account: AccountInfo, to: String, subject: String, body: String
let _ = sender.send(AppMessage::Status("Sending SMTP mail...".to_string()));
- let email = match Message::builder()
- .from(match account.email.parse() {
- Ok(f) => f,
- Err(e) => {
- let _ = sender.send(AppMessage::Status(format!("Invalid From Address: {}", e)));
- return;
- }
- })
- .to(match to.parse() {
+ let mut builder = Message::builder().from(match account.email.parse() {
+ Ok(f) => f,
+ Err(e) => {
+ let _ = sender.send(AppMessage::Status(format!("Invalid From Address: {}", e)));
+ return;
+ }
+ });
+ // The To line may hold several comma-separated addresses (a
+ // multi-recipient mailto: prefills it that way); each gets its own
+ // .to(), since lettre's Mailbox parse takes exactly one address.
+ for addr in to.split(',').map(str::trim).filter(|a| !a.is_empty()) {
+ builder = builder.to(match addr.parse() {
Ok(t) => t,
Err(e) => {
- let _ = sender.send(AppMessage::Status(format!("Invalid Recipient: {}", e)));
+ let _ = sender.send(AppMessage::Status(format!("Invalid Recipient {}: {}", addr, e)));
return;
}
- })
+ });
+ }
+ let email = match builder
.subject(subject)
.body(body) {
Ok(m) => m,
@@ -1600,6 +1678,25 @@ impl Application for ClearEmailApp {
let btn_compose_send = Button::new(0.0, 0.0, 75.0, 28.0).with_label("Send");
let btn_compose_cancel = Button::new_reset(0.0, 0.0, 75.0, 28.0).with_label("Cancel");
+ // A mailto: argv (this is the x-scheme-handler/mailto handler) opens
+ // the compose dialog prefilled. Both text and edit_buffer are set,
+ // same as ComposeNew/Reply: ComposeSend reads whichever side the
+ // editing flag selects, so a box left untouched must agree with one
+ // the user clicked into.
+ let mailto = std::env::args().nth(1).and_then(|a| parse_mailto(&a));
+ let (compose_open, compose_title) = match &mailto {
+ Some(m) => {
+ compose_to.text = m.to.clone();
+ compose_to.edit_buffer = m.to.clone();
+ compose_subject.text = m.subject.clone();
+ compose_subject.edit_buffer = m.subject.clone();
+ compose_body.text = m.body.clone();
+ compose_body.edit_buffer = m.body.clone();
+ (true, "New Message".to_string())
+ }
+ None => (false, String::new()),
+ };
+
let emails = if let Some(acc) = accounts.get(selected_account_idx) {
load_emails_for_account(&acc.email)
@@ -1643,8 +1740,8 @@ impl Application for ClearEmailApp {
detail_hovered: false,
body_sb_dragging: false,
body_sb_drag_offset: 0.0,
- compose_open: false,
- compose_title: String::new(),
+ compose_open,
+ compose_title,
status_message: None,
sender: _sender.clone(),
width: 1000,
@@ -2846,6 +2943,36 @@ fn main() {
mod tests {
use super::*;
+ #[test]
+ fn mailto_bare_address() {
+ let m = parse_mailto("mailto:[email protected]").unwrap();
+ assert_eq!(m.to, "[email protected]");
+ assert_eq!(m.subject, "");
+ assert_eq!(m.body, "");
+ }
+
+ #[test]
+ fn mailto_full_query() {
+ // %20 decodes; '+' stays literal (mailto is not form encoding);
+ // header names are case-insensitive; cc/bcc fold into To.
+ let m = parse_mailto(
+ "mailto:[email protected],[email protected]?Subject=Hello%20W%C3%B6rld&body=line1%0Aline2+plus&[email protected]&[email protected]&[email protected]",
+ )
+ .unwrap();
+ assert_eq!(m.to, "[email protected], [email protected], [email protected], [email protected], [email protected]");
+ assert_eq!(m.subject, "Hello Wörld");
+ assert_eq!(m.body, "line1\nline2+plus");
+ }
+
+ #[test]
+ fn mailto_rejects_non_mailto_and_keeps_bad_escapes() {
+ assert!(parse_mailto("https://example.com").is_none());
+ assert!(parse_mailto("[email protected]").is_none());
+ // Truncated/invalid %-escapes stay literal rather than erroring.
+ let m = parse_mailto("mailto:[email protected]?subject=100%25%2").unwrap();
+ assert_eq!(m.subject, "100%%2");
+ }
+
fn parse(raw: &str) -> mail_parser::Message<'_> {
mail_parser::MessageParser::default()
.parse(raw.as_bytes())