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

commit4f8d659e3b97c94c1d1753f1770c5ac20860b32a
parent41ab2d0a83
authorLucas Galante <[email protected]>
date2026-07-27 10:26
feat: Sent folder sync + recipient-first sent rows

- fetch_mailbox(): the whole two-pass BODYSTRUCTURE fetch extracted from
  sync_imap and parameterized by mailbox/folder-tag/id-offset (sent ids
  ride at +1_000_000 so seq numbers never collide across folders); From
  AND To now both come from the headers (format_to, account fallback)
- sync fetches INBOX, then quietly [Gmail]/Sent Mail with a generic
  "Sent" fallback (the imap crate quotes mailbox names itself — do NOT
  pre-quote; a server with neither name just syncs the inbox)
- merge: prior inbox and server-fetched sent rows are replaced wholesale;
  a locally-appended sent copy (uid None) is dropped once the server
  fetch carries the same subject (Gmail auto-saves SMTP sends)
- sent-folder rows lead with 'To: <recipient>' — every sent mail is from
  ourselves; the interesting party is the other end

Live-verified: 50 real Sent Mail messages with recipients and server
dates. (Fun Gmail semantics: a self-addressed test mail deleted from the
inbox left Sent too — trashing strips every label from the one message.)

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

 src/main.rs | 393 +++++++++++++++++++++++++++++++++++++-----------------------
 1 file changed, 244 insertions(+), 149 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 7fa9d3d..e173739 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -386,6 +386,21 @@ fn format_from(msg: &mail_parser::Message) -> String {
     }
 }
 
+/// Display form of the To address; sent-folder rows lead with this. Falls
+/// back to the account's own address (inbox mail is addressed to us anyway).
+fn format_to(msg: &mail_parser::Message, fallback: &str) -> String {
+    if let Some(addr) = msg.to().and_then(|a| a.first()) {
+        match (addr.name(), addr.address()) {
+            (Some(n), Some(a)) => format!("{} <{}>", n, a),
+            (None, Some(a)) => a.to_string(),
+            (Some(n), None) => n.to_string(),
+            (None, None) => fallback.to_string(),
+        }
+    } else {
+        fallback.to_string()
+    }
+}
+
 /// "Jul 23 10:50" from the parsed Date header (raw header strings previously
 /// showed as e.g. "Wed, 23 Jul 2026 10:50:12 +0200 (CEST)").
 fn format_date(dt: Option<&mail_parser::DateTime>) -> String {
@@ -672,171 +687,228 @@ fn open_imap_session(
     }
 }
 
-fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessage>) {
-    std::thread::spawn(move || {
-        if is_mock_account(&account) {
-            return;
-        }
-        let Some(mut session) = open_imap_session(&mut account, &sender, true) else {
-            return;
+/// Namespaces sent-folder ids away from inbox sequence numbers (both are
+/// fetch-time seq numbers; ids must stay unique across the merged list).
+const SENT_ID_OFFSET: usize = 1_000_000;
+
+/// Fetch the newest [`FETCH_COUNT`] messages of one mailbox with the two-pass
+/// BODYSTRUCTURE strategy (pass 1 headers/flags/structure, pass 2 text parts
+/// grouped by section, capped full-message fallback). Returns None when the
+/// mailbox can't be selected or a whole-mailbox step fails; per-message body
+/// fetch failures degrade to empty bodies. `verbose` gates the Status toasts
+/// (the sent fetch rides quietly behind the inbox one).
+fn fetch_mailbox(
+    session: &mut imap::Session<native_tls::TlsStream<std::net::TcpStream>>,
+    sender: &calloop::channel::Sender<AppMessage>,
+    account_email: &str,
+    mailbox: &str,
+    folder_tag: &str,
+    id_offset: usize,
+    verbose: bool,
+) -> Option<Vec<Email>> {
+    macro_rules! say {
+        ($msg:expr) => {
+            if verbose {
+                let _ = sender.send(AppMessage::Status($msg));
+            }
         };
+    }
+    if let Err(e) = session.select(mailbox) {
+        say!(format!("Failed to select {}: {}", mailbox, e));
+        return None;
+    }
 
-        let _ = sender.send(AppMessage::Status("Syncing Inbox...".to_string()));
-
-        if let Err(e) = session.select("INBOX") {
-            let _ = sender.send(AppMessage::Status(format!("Failed to select INBOX: {}", e)));
-            let _ = session.logout();
-            return;
+    let mut search_results: Vec<u32> = match session.search("ALL") {
+        Ok(ids) => ids.into_iter().collect(),
+        Err(e) => {
+            say!(format!("IMAP Search failed: {}", e));
+            return None;
         }
+    };
+    search_results.sort();
 
-        let mut search_results: Vec<u32> = match session.search("ALL") {
-            Ok(ids) => ids.into_iter().collect(),
-            Err(e) => {
-                let _ = sender.send(AppMessage::Status(format!("IMAP Search failed: {}", e)));
-                let _ = session.logout();
-                return;
-            }
-        };
-        search_results.sort();
+    let total = search_results.len();
+    if total == 0 {
+        return Some(Vec::new());
+    }
 
-        let total = search_results.len();
-        if total == 0 {
-            let _ = sender.send(AppMessage::Status("Inbox is empty".to_string()));
-            let _ = session.logout();
-            return;
+    // Pass 1: flags + headers + BODYSTRUCTURE — no body bytes yet, so
+    // attachments never ride along. PEEK semantics don't matter here
+    // (headers/structure don't set \Seen), but flags come from the server.
+    let start_idx = total.saturating_sub(FETCH_COUNT);
+    let range = &search_results[start_idx..total];
+    let query_seq = range.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
+    say!(format!("Fetching {} messages...", range.len()));
+
+    struct Pending {
+        seq: u32,
+        uid: Option<u32>,
+        read: bool,
+        from: String,
+        to: String,
+        subject: String,
+        date: String,
+        part: Option<TextPartSpec>,
+        body: String,
+    }
+    let parser = mail_parser::MessageParser::default();
+    let mut pending: Vec<Pending> = Vec::new();
+    match session.fetch(&query_seq, "(UID FLAGS RFC822.HEADER BODYSTRUCTURE)") {
+        Ok(fetches) => {
+            for fetch in fetches.iter() {
+                let read = fetch
+                    .flags()
+                    .iter()
+                    .any(|f| matches!(f, imap::types::Flag::Seen));
+                let (from, to, subject, date) = match fetch.header().and_then(|h| parser.parse(h)) {
+                    Some(msg) => (
+                        format_from(&msg),
+                        format_to(&msg, account_email),
+                        msg.subject().unwrap_or("(No Subject)").to_string(),
+                        format_date(msg.date()),
+                    ),
+                    None => (
+                        "Unknown".to_string(),
+                        account_email.to_string(),
+                        "(No Subject)".to_string(),
+                        "Unknown".to_string(),
+                    ),
+                };
+                pending.push(Pending {
+                    seq: fetch.message,
+                    uid: fetch.uid,
+                    read,
+                    from,
+                    to,
+                    subject,
+                    date,
+                    part: fetch.bodystructure().and_then(find_text_part),
+                    body: String::new(),
+                });
+            }
+        }
+        Err(e) => {
+            say!(format!("IMAP Fetch failed: {}", e));
+            return None;
         }
+    }
 
-        // Pass 1: flags + headers + BODYSTRUCTURE — no body bytes yet, so
-        // attachments never ride along. PEEK semantics don't matter here
-        // (headers/structure don't set \Seen), but flags come from the server.
-        let start_idx = total.saturating_sub(FETCH_COUNT);
-        let range = &search_results[start_idx..total];
-        let query_seq = range.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
-        let _ = sender.send(AppMessage::Status(format!("Fetching {} messages...", range.len())));
-
-        struct Pending {
-            seq: u32,
-            uid: Option<u32>,
-            read: bool,
-            from: String,
-            subject: String,
-            date: String,
-            part: Option<TextPartSpec>,
-            body: String,
-        }
-        let parser = mail_parser::MessageParser::default();
-        let mut pending: Vec<Pending> = Vec::new();
-        match session.fetch(&query_seq, "(UID FLAGS RFC822.HEADER BODYSTRUCTURE)") {
+    // Pass 2: fetch just the chosen text part, grouped by section path so a
+    // typical mailbox needs only 1-2 more round trips; capped so a giant
+    // text part can't stall the sync either.
+    let mut groups: std::collections::HashMap<Vec<u32>, Vec<u32>> = std::collections::HashMap::new();
+    for p in &pending {
+        if let Some(spec) = &p.part {
+            groups.entry(spec.path.clone()).or_default().push(p.seq);
+        }
+    }
+    for (path, seqs) in groups {
+        let seq_set = seqs.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
+        let section = section_str(&path);
+        let query = format!("BODY.PEEK[{}]<0.{}>", section, PART_FETCH_CAP);
+        let section_path = imap_proto::types::SectionPath::Part(path, None);
+        match session.fetch(&seq_set, &query) {
             Ok(fetches) => {
                 for fetch in fetches.iter() {
-                    let read = fetch
-                        .flags()
-                        .iter()
-                        .any(|f| matches!(f, imap::types::Flag::Seen));
-                    let (from, subject, date) = match fetch.header().and_then(|h| parser.parse(h)) {
-                        Some(msg) => (
-                            format_from(&msg),
-                            msg.subject().unwrap_or("(No Subject)").to_string(),
-                            format_date(msg.date()),
-                        ),
-                        None => (
-                            "Unknown".to_string(),
-                            "(No Subject)".to_string(),
-                            "Unknown".to_string(),
-                        ),
-                    };
-                    pending.push(Pending {
-                        seq: fetch.message,
-                        uid: fetch.uid,
-                        read,
-                        from,
-                        subject,
-                        date,
-                        part: fetch.bodystructure().and_then(find_text_part),
-                        body: String::new(),
-                    });
+                    let Some(bytes) = fetch.section(&section_path) else { continue };
+                    if let Some(p) = pending.iter_mut().find(|p| p.seq == fetch.message) {
+                        if let Some(spec) = &p.part {
+                            p.body = parser
+                                .parse(&spec.synthesize(bytes))
+                                .map(|m| extract_body(&m))
+                                .unwrap_or_default();
+                        }
+                    }
                 }
             }
             Err(e) => {
-                let _ = sender.send(AppMessage::Status(format!("IMAP Fetch failed: {}", e)));
-                let _ = session.logout();
-                return;
+                say!(format!("IMAP part fetch failed: {}", e));
             }
         }
+    }
 
-        // Pass 2: fetch just the chosen text part, grouped by section path so a
-        // typical mailbox needs only 1-2 more round trips; capped so a giant
-        // text part can't stall the sync either.
-        let mut groups: std::collections::HashMap<Vec<u32>, Vec<u32>> = std::collections::HashMap::new();
-        for p in &pending {
-            if let Some(spec) = &p.part {
-                groups.entry(spec.path.clone()).or_default().push(p.seq);
-            }
-        }
-        for (path, seqs) in groups {
-            let seq_set = seqs.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
-            let section = section_str(&path);
-            let query = format!("BODY.PEEK[{}]<0.{}>", section, PART_FETCH_CAP);
-            let section_path = imap_proto::types::SectionPath::Part(path, None);
-            match session.fetch(&seq_set, &query) {
-                Ok(fetches) => {
-                    for fetch in fetches.iter() {
-                        let Some(bytes) = fetch.section(&section_path) else { continue };
-                        if let Some(p) = pending.iter_mut().find(|p| p.seq == fetch.message) {
-                            if let Some(spec) = &p.part {
-                                p.body = parser
-                                    .parse(&spec.synthesize(bytes))
-                                    .map(|m| extract_body(&m))
-                                    .unwrap_or_default();
-                            }
-                        }
+    // Fallback: no usable text part in the structure (or the walk failed) —
+    // one capped full-message fetch for those stragglers.
+    let no_part: Vec<u32> = pending.iter().filter(|p| p.part.is_none()).map(|p| p.seq).collect();
+    if !no_part.is_empty() {
+        let seq_set = no_part.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
+        let query = format!("BODY.PEEK[]<0.{}>", PART_FETCH_CAP * 4);
+        match session.fetch(&seq_set, &query) {
+            Ok(fetches) => {
+                for fetch in fetches.iter() {
+                    let Some(raw) = fetch.body() else { continue };
+                    if let Some(p) = pending.iter_mut().find(|p| p.seq == fetch.message) {
+                        p.body = parser.parse(raw).map(|m| extract_body(&m)).unwrap_or_default();
                     }
                 }
-                Err(e) => {
-                    let _ = sender.send(AppMessage::Status(format!("IMAP part fetch failed: {}", e)));
-                }
+            }
+            Err(e) => {
+                say!(format!("IMAP fallback fetch failed: {}", e));
             }
         }
+    }
 
-        // Fallback: no usable text part in the structure (or the walk failed) —
-        // one capped full-message fetch for those stragglers.
-        let no_part: Vec<u32> = pending.iter().filter(|p| p.part.is_none()).map(|p| p.seq).collect();
-        if !no_part.is_empty() {
-            let seq_set = no_part.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
-            let query = format!("BODY.PEEK[]<0.{}>", PART_FETCH_CAP * 4);
-            match session.fetch(&seq_set, &query) {
-                Ok(fetches) => {
-                    for fetch in fetches.iter() {
-                        let Some(raw) = fetch.body() else { continue };
-                        if let Some(p) = pending.iter_mut().find(|p| p.seq == fetch.message) {
-                            p.body = parser.parse(raw).map(|m| extract_body(&m)).unwrap_or_default();
-                        }
-                    }
-                }
-                Err(e) => {
-                    let _ = sender.send(AppMessage::Status(format!("IMAP fallback fetch failed: {}", e)));
-                }
-            }
+    let mut fetched: Vec<Email> = pending
+        .into_iter()
+        .map(|p| Email {
+            id: p.seq as usize + id_offset,
+            uid: p.uid,
+            from: p.from,
+            to: p.to,
+            subject: p.subject,
+            body: p.body,
+            date: p.date,
+            read: p.read,
+            folder: folder_tag.to_string(),
+        })
+        .collect();
+
+    fetched.reverse(); // Newest first
+    Some(fetched)
+}
+
+fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessage>) {
+    std::thread::spawn(move || {
+        if is_mock_account(&account) {
+            return;
         }
+        let Some(mut session) = open_imap_session(&mut account, &sender, true) else {
+            return;
+        };
 
-        let mut fetched_emails: Vec<Email> = pending
-            .into_iter()
-            .map(|p| Email {
-                id: p.seq as usize,
-                uid: p.uid,
-                from: p.from,
-                to: account.email.clone(),
-                subject: p.subject,
-                body: p.body,
-                date: p.date,
-                read: p.read,
-                folder: "inbox".to_string(),
-            })
-            .collect();
+        let _ = sender.send(AppMessage::Status("Syncing Inbox...".to_string()));
 
-        fetched_emails.reverse(); // Newest first
+        let Some(mut fetched) = fetch_mailbox(
+            &mut session,
+            &sender,
+            &account.email,
+            "INBOX",
+            "inbox",
+            0,
+            true,
+        ) else {
+            let _ = session.logout();
+            return;
+        };
+
+        // Sent rides along quietly: Gmail's name first, the conventional one
+        // second; a server with neither just syncs the inbox.
+        for mailbox in ["[Gmail]/Sent Mail", "Sent"] {
+            if let Some(sent) = fetch_mailbox(
+                &mut session,
+                &sender,
+                &account.email,
+                mailbox,
+                "sent",
+                SENT_ID_OFFSET,
+                false,
+            ) {
+                fetched.extend(sent);
+                break;
+            }
+        }
 
-        let _ = sender.send(AppMessage::EmailsSynced(account.email.clone(), fetched_emails));
+        let _ = sender.send(AppMessage::EmailsSynced(account.email.clone(), fetched));
         let _ = sender.send(AppMessage::Status("Sync Complete".to_string()));
         let _ = session.logout();
     });
@@ -1233,9 +1305,15 @@ impl ClearEmailApp {
 
             for (idx, email) in filtered.iter().enumerate() {
                 if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
-                    // Sender name
+                    // Sender — recipient on sent rows (every sent mail is
+                    // "from" ourselves; the interesting party is the other end)
+                    let row_head = if email.folder == "sent" {
+                        format!("To: {}", email.to)
+                    } else {
+                        email.from.clone()
+                    };
                     labels.push(TextLabel {
-                        text: ellipsize(&email.from, 21),
+                        text: ellipsize(&row_head, 21),
                         x: list_x + 20.0,
                         y: draw_y + 6.0,
                         font_size: 11.0,
@@ -1748,19 +1826,36 @@ impl Application for ClearEmailApp {
                             })
                             .collect()
                     };
+                    // Locally-kept mail alongside a fresh fetch: everything
+                    // except the server-backed folders — prior inbox rows are
+                    // replaced wholesale, prior server-fetched sent (uid set)
+                    // likewise, and a locally-appended sent copy (uid None)
+                    // is dropped once the server fetch carries the same
+                    // message (Gmail auto-saves SMTP sends to Sent Mail).
+                    let local_keep = |prior: &[Email], fetched: &[Email]| -> Vec<Email> {
+                        prior
+                            .iter()
+                            .filter(|e| e.folder != "inbox")
+                            .filter(|e| !(e.folder == "sent" && e.uid.is_some()))
+                            .filter(|e| {
+                                !(e.folder == "sent"
+                                    && fetched.iter().any(|f| {
+                                        f.folder == "sent" && f.subject == e.subject
+                                    }))
+                            })
+                            .cloned()
+                            .collect()
+                    };
                     if acc.email == email {
                         let mut merged = keep_local_read(&self.emails, list.clone());
-                        let mut local_other = self.emails.clone();
-                        local_other.retain(|e| e.folder != "inbox");
-                        merged.extend(local_other);
+                        merged.extend(local_keep(&self.emails, &list));
                         self.emails = merged;
                         save_emails_for_account(&email, &self.emails);
                     } else {
                         // Just write cache to disk
-                        let mut acc_emails = load_emails_for_account(&email);
+                        let acc_emails = load_emails_for_account(&email);
                         let mut merged = keep_local_read(&acc_emails, list.clone());
-                        acc_emails.retain(|e| e.folder != "inbox");
-                        merged.extend(acc_emails);
+                        merged.extend(local_keep(&acc_emails, &list));
                         save_emails_for_account(&email, &merged);
                     }
                 }