// Everything that puts a row in the mirror. // // Two facts are derived here and kept as columns rather than worked out on read: the portable // thread key, and the routing headers. Both are read on every row of a list page, and a JSON // extract per row over a first sync is the difference between a second and a minute. // // The system label names below are the one place in the app outside the provider module that // knows what a Gmail label is called. `RawHeaders` carries `label_ids` and nothing else, so the // seen, starred, archived, trashed and spam flags have nowhere else to come from. An IMAP // provider maps `\Seen` and `X-GM-LABELS` onto the same names before the mirror sees them. use std::sync::atomic::{AtomicU64, Ordering}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use crate::dto::{FlagPatch, Person}; use crate::mime::{self, RenderOptions}; use crate::provider::RawHeaders; pub const LABEL_INBOX: &str = "INBOX"; pub const LABEL_UNREAD: &str = "UNREAD"; pub const LABEL_STARRED: &str = "STARRED"; pub const LABEL_TRASH: &str = "TRASH"; pub const LABEL_SPAM: &str = "SPAM"; pub const LABEL_SENT: &str = "SENT"; pub const LABEL_DRAFT: &str = "DRAFT"; pub const CURSOR_KEY: &str = "sync-cursor"; pub const LAST_SYNC_KEY: &str = "last-sync"; pub const WINDOW_KEY: &str = "window-days"; pub const BACKFILL_KEY: &str = "backfill-after"; pub const OWN_ADDRESS_KEY: &str = "own-address"; pub const ACCOUNT_COLOR_KEY: &str = "account-color"; pub const FIRST_SYNC_KEY: &str = "first-sync-done"; /// The default window, in days. Thirty days is what a mail client is actually used for. pub const DEFAULT_WINDOW_DAYS: i64 = 30; pub const DAY_MS: i64 = 86_400_000; pub fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } /// Unique within the process and monotonic within a millisecond, which is all an outbox row or an /// undo token needs. There is no uuid dependency here and adding one to name a queue entry would /// be a poor trade. pub fn fresh_id(prefix: &str) -> String { static NEXT: AtomicU64 = AtomicU64::new(1); format!("{prefix}-{}-{}", now_ms(), NEXT.fetch_add(1, Ordering::Relaxed)) } pub fn meta_get(conn: &Connection, key: &str) -> Result, String> { conn.query_row("SELECT value FROM meta WHERE key = ?1", [key], |row| { row.get(0) }) .optional() .map_err(|e| e.to_string()) } pub fn meta_set(conn: &Connection, key: &str, value: &str) -> Result<(), String> { conn.execute( "INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], ) .map(|_| ()) .map_err(|e| e.to_string()) } pub fn meta_i64(conn: &Connection, key: &str) -> Result, String> { Ok(meta_get(conn, key)?.and_then(|v| v.parse().ok())) } pub fn meta_clear(conn: &Connection, key: &str) -> Result<(), String> { conn.execute("DELETE FROM meta WHERE key = ?1", [key]) .map(|_| ()) .map_err(|e| e.to_string()) } /// How far back this account keeps mail, in days. Zero is everything. pub fn window_days(conn: &Connection) -> Result { Ok(meta_i64(conn, WINDOW_KEY)?.unwrap_or(DEFAULT_WINDOW_DAYS)) } /// The moment the window starts, or None when the account keeps everything. pub fn window_start(conn: &Connection, now: i64) -> Result, String> { let days = window_days(conn)?; Ok((days > 0).then(|| now - days * DAY_MS)) } // --------------------------------------------------------------------------------------------- // Headers // --------------------------------------------------------------------------------------------- /// A `Message-ID` without its angle brackets, which is the form the state database keys on. pub fn bare_id(raw: &str) -> Option { let trimmed = raw.trim().trim_start_matches('<').trim_end_matches('>').trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } /// The first entry of a `References` or `In-Reply-To` header. Folding has already been undone by /// the provider, so the entries are separated by whitespace. pub fn first_reference(raw: &str) -> Option { raw.split_whitespace().find_map(bare_id) } /// The portable thread key: the first entry of `References`, else `In-Reply-To`, else the /// message's own `Message-ID`. /// /// The fallback is the provider's thread id, which is the honest failure: a message with no /// `Message-ID` at all cannot have a key that survives a change of provider, and borrowing the /// provider's id at least keeps the conversation together on this device. pub fn thread_key( references: Option<&str>, in_reply_to: Option<&str>, message_id: Option<&str>, provider_thread_id: &str, ) -> String { references .and_then(first_reference) .or_else(|| in_reply_to.and_then(first_reference)) .or_else(|| message_id.and_then(bare_id)) .unwrap_or_else(|| format!("provider:{provider_thread_id}")) } /// Splits an address list on the commas that are not inside a quoted display name or a set of /// angle brackets. Addresses are lowercased because every lookup in the app, from a sender rule to /// a contact card, treats them case-insensitively. pub fn addresses(header: &str) -> Vec { let mut out = Vec::new(); let mut current = String::new(); let mut quoted = false; let mut depth = 0i32; for c in header.chars() { match c { '"' => { quoted = !quoted; current.push(c); } '<' if !quoted => { depth += 1; current.push(c); } '>' if !quoted => { depth -= 1; current.push(c); } ',' if !quoted && depth <= 0 => { push_person(&mut out, ¤t); current.clear(); } _ => current.push(c), } } push_person(&mut out, ¤t); out } fn push_person(out: &mut Vec, raw: &str) { let raw = raw.trim(); if raw.is_empty() { return; } let (name, address) = match (raw.find('<'), raw.rfind('>')) { (Some(open), Some(close)) if close > open => ( raw[..open].trim().to_string(), raw[open + 1..close].trim().to_string(), ), _ => (String::new(), raw.to_string()), }; let name = name.trim().trim_matches('"').trim().to_string(); if address.is_empty() { return; } out.push(Person { name: (!name.is_empty()).then_some(name), address: address.to_lowercase(), }); } pub fn one_address(header: &str) -> Person { addresses(header).into_iter().next().unwrap_or(Person { name: None, address: String::new(), }) } fn json(value: &impl Serialize) -> String { serde_json::to_string(value).unwrap_or_else(|_| "[]".to_string()) } // --------------------------------------------------------------------------------------------- // Messages and threads // --------------------------------------------------------------------------------------------- fn has_label(labels: &[String], name: &str) -> bool { labels.iter().any(|l| l == name) } /// A row from `messages.list` that has not been hydrated yet. The thread key is a placeholder /// until the headers arrive, because there is nothing to derive one from. pub fn note_listed(conn: &Connection, id: &str, provider_thread_id: &str) -> Result<(), String> { conn.execute( "INSERT OR IGNORE INTO messages (id, provider_thread_id, thread_key, hydrated) VALUES (?1, ?2, ?3, 0)", params![id, provider_thread_id, format!("provider:{provider_thread_id}")], ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Ids waiting for their metadata, oldest listing first. The listing arrives newest first, so /// insertion order is hydration order and the newest mail lands first. pub fn unhydrated(conn: &Connection, limit: usize) -> Result, String> { let mut stmt = conn .prepare("SELECT id FROM messages WHERE hydrated = 0 ORDER BY rowid LIMIT ?1") .map_err(|e| e.to_string())?; let rows = stmt .query_map([limit as i64], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } pub fn count(conn: &Connection, sql: &str) -> Result { conn.query_row(sql, [], |row| row.get::<_, i64>(0)) .map(|n| n as u32) .map_err(|e| e.to_string()) } /// Upserts one message from its metadata and returns the thread key it derived. pub fn upsert_message( conn: &Connection, headers: &RawHeaders, transient: bool, ) -> Result { let message_id = headers.header("message-id").and_then(bare_id); let in_reply_to = headers.header("in-reply-to").and_then(first_reference); let references_first = headers.header("references").and_then(first_reference); let key = thread_key( headers.header("references"), headers.header("in-reply-to"), headers.header("message-id"), &headers.thread_id, ); let from = headers.header("from").map(one_address).unwrap_or(Person { name: None, address: String::new(), }); let to = headers.header("to").map(addresses).unwrap_or_default(); let cc = headers.header("cc").map(addresses).unwrap_or_default(); let bcc = headers.header("bcc").map(addresses).unwrap_or_default(); let reply_to = headers.header("reply-to").map(addresses).unwrap_or_default(); let labels = &headers.label_ids; // Metadata carries no part list, so this is a guess off `Content-Type` and it is corrected the // moment the body lands and the real parts are known. let has_attachment = headers .header("content-type") .map(|value| value.to_ascii_lowercase().contains("multipart/mixed")) .unwrap_or(false); conn.execute( "INSERT INTO messages ( id, provider_thread_id, thread_key, message_id, in_reply_to, references_first, date_ms, from_name, from_address, to_json, cc_json, bcc_json, reply_to_json, subject, snippet, seen, starred, draft, sent, labels, list_id, list_unsubscribe, list_unsub_post, auto_submitted, precedence, size, has_attachment, hydrated, transient ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, 1, ?28 ) ON CONFLICT(id) DO UPDATE SET provider_thread_id = excluded.provider_thread_id, thread_key = excluded.thread_key, message_id = excluded.message_id, in_reply_to = excluded.in_reply_to, references_first = excluded.references_first, date_ms = excluded.date_ms, from_name = excluded.from_name, from_address = excluded.from_address, to_json = excluded.to_json, cc_json = excluded.cc_json, bcc_json = excluded.bcc_json, reply_to_json = excluded.reply_to_json, subject = excluded.subject, snippet = excluded.snippet, seen = excluded.seen, starred = excluded.starred, draft = excluded.draft, sent = excluded.sent, labels = excluded.labels, list_id = excluded.list_id, list_unsubscribe = excluded.list_unsubscribe, list_unsub_post = excluded.list_unsub_post, auto_submitted = excluded.auto_submitted, precedence = excluded.precedence, size = excluded.size, has_attachment = messages.has_attachment OR excluded.has_attachment, hydrated = 1, transient = messages.transient AND excluded.transient", params![ headers.id, headers.thread_id, key, message_id, in_reply_to, references_first, headers.internal_date_ms, from.name, from.address, json(&to), json(&cc), json(&bcc), json(&reply_to), headers.header("subject").unwrap_or("").trim(), headers.snippet, !has_label(labels, LABEL_UNREAD) as i64, has_label(labels, LABEL_STARRED) as i64, has_label(labels, LABEL_DRAFT) as i64, has_label(labels, LABEL_SENT) as i64, json(labels), headers.header("list-id"), headers.header("list-unsubscribe"), headers.header("list-unsubscribe-post"), headers.header("auto-submitted"), headers.header("precedence"), headers.size as i64, has_attachment as i64, transient as i64, ], ) .map_err(|e| e.to_string())?; remember_correspondents(conn, &from, &to, headers.internal_date_ms, has_label(labels, LABEL_SENT))?; // Indexed on its headers now rather than when a body arrives, because a body may never arrive: // the hits of a provider search are metadata only, and a result list is a query over this // index. `store_body` writes the row again with the text once there is text. super::fts::index(conn, &headers.id)?; Ok(key) } fn remember_correspondents( conn: &Connection, from: &Person, to: &[Person], date_ms: i64, sent: bool, ) -> Result<(), String> { let mut people: Vec<(&Person, bool)> = vec![(from, false)]; people.extend(to.iter().map(|person| (person, sent))); for (person, outgoing) in people { if person.address.is_empty() { continue; } conn.execute( "INSERT INTO correspondents (address, name, last_ms, seen_count, sent_count, source) VALUES (?1, ?2, ?3, 1, ?4, 'mirror') ON CONFLICT(address) DO UPDATE SET name = COALESCE(excluded.name, correspondents.name), last_ms = MAX(correspondents.last_ms, excluded.last_ms), seen_count = correspondents.seen_count + 1, sent_count = correspondents.sent_count + ?4", params![person.address, person.name, date_ms, outgoing as i64], ) .map_err(|e| e.to_string())?; } Ok(()) } /// Recomputes one thread's denormalised columns from its messages. Called once per hydration batch /// rather than once per message, because a thread's aggregate is the same either way and reading /// every message of a thread per message written is quadratic. pub fn refresh_thread(conn: &Connection, provider_thread_id: &str) -> Result<(), String> { struct Row { thread_key: String, date_ms: i64, subject: String, snippet: String, from_name: Option, from_address: String, to_json: String, seen: bool, starred: bool, draft: bool, has_attachment: bool, labels: String, transient: bool, } let mut stmt = conn .prepare( "SELECT thread_key, date_ms, subject, snippet, from_name, from_address, to_json, seen, starred, draft, has_attachment, labels, transient FROM messages WHERE provider_thread_id = ?1 AND hydrated = 1 ORDER BY date_ms ASC, id ASC", ) .map_err(|e| e.to_string())?; let rows: Vec = stmt .query_map([provider_thread_id], |row| { Ok(Row { thread_key: row.get(0)?, date_ms: row.get(1)?, subject: row.get(2)?, snippet: row.get(3)?, from_name: row.get(4)?, from_address: row.get(5)?, to_json: row.get(6)?, seen: row.get::<_, i64>(7)? != 0, starred: row.get::<_, i64>(8)? != 0, draft: row.get::<_, i64>(9)? != 0, has_attachment: row.get::<_, i64>(10)? != 0, labels: row.get(11)?, transient: row.get::<_, i64>(12)? != 0, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; if rows.is_empty() { conn.execute( "DELETE FROM threads WHERE provider_thread_id = ?1", [provider_thread_id], ) .map_err(|e| e.to_string())?; return Ok(()); } let latest = rows.last().expect("a non-empty thread"); let mut participants: Vec = Vec::new(); let mut in_inbox = false; let mut trashed = false; let mut spam = false; let mut unseen = false; let mut starred = false; let mut has_attachment = false; let mut has_draft = false; let mut transient = true; for row in &rows { let labels: Vec = serde_json::from_str(&row.labels).unwrap_or_default(); in_inbox |= has_label(&labels, LABEL_INBOX); trashed |= has_label(&labels, LABEL_TRASH); spam |= has_label(&labels, LABEL_SPAM); unseen |= !row.seen && !row.draft; starred |= row.starred; has_attachment |= row.has_attachment; has_draft |= row.draft; transient &= row.transient; let from = Person { name: row.from_name.clone(), address: row.from_address.clone(), }; merge_person(&mut participants, from); for person in serde_json::from_str::>(&row.to_json).unwrap_or_default() { merge_person(&mut participants, person); } } conn.execute( "INSERT INTO threads ( provider_thread_id, thread_key, latest_ms, message_count, unseen, starred, has_attachment, has_draft, in_inbox, trashed, spam, subject, snippet, from_name, from_address, participants, transient ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) ON CONFLICT(provider_thread_id) DO UPDATE SET thread_key = excluded.thread_key, latest_ms = excluded.latest_ms, message_count = excluded.message_count, unseen = excluded.unseen, starred = excluded.starred, has_attachment = excluded.has_attachment, has_draft = excluded.has_draft, in_inbox = excluded.in_inbox, trashed = excluded.trashed, spam = excluded.spam, subject = excluded.subject, snippet = excluded.snippet, from_name = excluded.from_name, from_address = excluded.from_address, participants = excluded.participants, transient = excluded.transient", params![ provider_thread_id, rows[0].thread_key, latest.date_ms, rows.len() as i64, unseen as i64, starred as i64, has_attachment as i64, has_draft as i64, in_inbox as i64, trashed as i64, spam as i64, rows.iter().find(|r| !r.subject.is_empty()).map(|r| r.subject.clone()).unwrap_or_default(), latest.snippet, latest.from_name, latest.from_address, json(&participants), transient as i64, ], ) .map_err(|e| e.to_string())?; Ok(()) } fn merge_person(into: &mut Vec, person: Person) { if person.address.is_empty() { return; } match into.iter_mut().find(|held| held.address == person.address) { Some(held) => { if held.name.is_none() { held.name = person.name; } } None => into.push(person), } } pub fn thread_id_of(conn: &Connection, message_id: &str) -> Result, String> { conn.query_row( "SELECT provider_thread_id FROM messages WHERE id = ?1", [message_id], |row| row.get(0), ) .optional() .map_err(|e| e.to_string()) } /// The provider ids of every message in the threads a set of portable keys names, including the /// threads merged into them. pub fn message_ids_for_keys(conn: &Connection, keys: &[String]) -> Result, String> { let mut out = Vec::new(); for key in keys { let mut stmt = conn .prepare( "SELECT m.id FROM messages m WHERE m.provider_thread_id IN ( SELECT provider_thread_id FROM threads WHERE thread_key = ?1 OR thread_key IN (SELECT thread_key FROM state.merges WHERE merged_key = ?1) )", ) .map_err(|e| e.to_string())?; let ids = stmt .query_map([key], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; out.extend(ids); } out.sort(); out.dedup(); Ok(out) } /// What a message's flags were before a change, which is what an undo needs and what a frontend /// cannot reconstruct after a bulk action over mixed prior state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PriorFlags { pub id: String, pub labels: Vec, } pub fn prior_flags(conn: &Connection, ids: &[String]) -> Result, String> { let mut out = Vec::new(); for id in ids { let labels: Option = conn .query_row("SELECT labels FROM messages WHERE id = ?1", [id], |row| { row.get(0) }) .optional() .map_err(|e| e.to_string())?; if let Some(labels) = labels { out.push(PriorFlags { id: id.clone(), labels: serde_json::from_str(&labels).unwrap_or_default(), }); } } Ok(out) } /// The label a flag maps onto, and whether setting the flag adds it. Seen and archived are both /// inversions, which is the sort of thing worth having in exactly one place. fn label_for(flag: &str) -> (&'static str, bool) { match flag { "seen" => (LABEL_UNREAD, false), "starred" => (LABEL_STARRED, true), "archived" => (LABEL_INBOX, false), "trashed" => (LABEL_TRASH, true), "spam" => (LABEL_SPAM, true), _ => ("", true), } } fn apply_to_labels(labels: &mut Vec, patch: &FlagPatch) { for (flag, value) in [ ("seen", patch.seen), ("starred", patch.starred), ("archived", patch.archived), ("trashed", patch.trashed), ("spam", patch.spam), ] { let Some(on) = value else { continue }; let (label, adds_when_on) = label_for(flag); if label.is_empty() { continue; } let should_hold = if adds_when_on { on } else { !on }; let held = labels.iter().any(|l| l == label); if should_hold && !held { labels.push(label.to_string()); } else if !should_hold && held { labels.retain(|l| l != label); } } } /// Lands a flag change in the mirror. The push to the provider is queued separately, because the /// write is optimistic: it shows before it goes. pub fn apply_flags( conn: &Connection, ids: &[String], patch: &FlagPatch, ) -> Result, String> { let mut threads = Vec::new(); for id in ids { let held: Option<(String, String)> = conn .query_row( "SELECT labels, provider_thread_id FROM messages WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(|e| e.to_string())?; let Some((labels, thread_id)) = held else { continue; }; let mut labels: Vec = serde_json::from_str(&labels).unwrap_or_default(); apply_to_labels(&mut labels, patch); set_labels_row(conn, id, &labels)?; if !threads.contains(&thread_id) { threads.push(thread_id); } } for thread_id in &threads { refresh_thread(conn, thread_id)?; } Ok(threads) } /// Lands the label set the provider reported for a message. An undo wants `apply_labels` instead, /// for the reason below. /// /// What the provider says goes under whatever this device has queued and not yet pushed. A sync /// pass that fetched its metadata before an open landed still carries `UNREAD` for the thread, and /// writing that would put the dot back for the twelve seconds until the outbox row it crossed /// reaches the server; an archive and `INBOX` are the same race. Mailspring's rule, and the honest /// one: a change this device made is the truth about those labels until the server has heard it. pub fn set_labels(conn: &Connection, id: &str, labels: &[String]) -> Result, String> { let thread_id = thread_id_of(conn, id)?; if thread_id.is_some() { let labels = under_pending(conn, id, labels)?; set_labels_row(conn, id, &labels)?; } Ok(thread_id) } /// A reported label set with every change still waiting in the outbox for this message replayed /// over it, oldest row first so that a later change wins the way it did when it was made. An /// empty outbox, which is nearly always, is one query over an empty table and the set unchanged. fn under_pending(conn: &Connection, id: &str, labels: &[String]) -> Result, String> { let mut stmt = conn .prepare( "SELECT op, payload FROM outbox WHERE op IN (?1, ?2) AND EXISTS (SELECT 1 FROM json_each(outbox.payload, '$.ids') WHERE json_each.value = ?3) ORDER BY created_at ASC, id ASC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![OP_FLAGS, OP_LABELS, id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; let mut labels = labels.to_vec(); for (op, payload) in rows { let Ok(payload) = serde_json::from_str::(&payload) else { continue; }; let field = |name: &str| payload.get(name).cloned().unwrap_or(serde_json::Value::Null); if op == OP_FLAGS { if let Ok(patch) = serde_json::from_value::(field("patch")) { apply_to_labels(&mut labels, &patch); } } else { let add: Vec = serde_json::from_value(field("add")).unwrap_or_default(); let remove: Vec = serde_json::from_value(field("remove")).unwrap_or_default(); for label in add { if !labels.contains(&label) { labels.push(label); } } labels.retain(|l| !remove.contains(l)); } } Ok(labels) } fn set_labels_row(conn: &Connection, id: &str, labels: &[String]) -> Result<(), String> { conn.execute( "UPDATE messages SET labels = ?2, seen = ?3, starred = ?4, draft = ?5, sent = ?6 WHERE id = ?1", params![ id, json(&labels.to_vec()), !has_label(labels, LABEL_UNREAD) as i64, has_label(labels, LABEL_STARRED) as i64, has_label(labels, LABEL_DRAFT) as i64, has_label(labels, LABEL_SENT) as i64, ], ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Records a label change locally. Returns the threads that need refreshing. pub fn apply_labels( conn: &Connection, ids: &[String], add: &[String], remove: &[String], ) -> Result, String> { let mut threads = Vec::new(); for id in ids { let held: Option<(String, String)> = conn .query_row( "SELECT labels, provider_thread_id FROM messages WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(|e| e.to_string())?; let Some((labels, thread_id)) = held else { continue; }; let mut labels: Vec = serde_json::from_str(&labels).unwrap_or_default(); for label in add { if !labels.contains(label) { labels.push(label.clone()); } } labels.retain(|l| !remove.contains(l)); set_labels_row(conn, id, &labels)?; if !threads.contains(&thread_id) { threads.push(thread_id); } } for thread_id in &threads { refresh_thread(conn, thread_id)?; } Ok(threads) } pub fn upsert_labels( conn: &Connection, labels: &[crate::provider::ProviderLabel], ) -> Result<(), String> { for label in labels { conn.execute( "INSERT INTO labels (id, name, kind) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO UPDATE SET name = excluded.name, kind = excluded.kind", params![label.id, label.name, label.kind], ) .map_err(|e| e.to_string())?; } Ok(()) } // --------------------------------------------------------------------------------------------- // Bodies and attachments // --------------------------------------------------------------------------------------------- /// Stores a body and whatever the pipeline could make of it. /// /// A body that will not render is still stored, with its render left empty and its version left at /// zero, so the thread opens with its headers rather than not at all. `RENDER_VERSION` on the row /// is what makes that recoverable: the next open of a message whose stamp is not the current one /// renders it again from the raw bytes it kept. /// The first line or so of a message, for the list row. /// /// Whitespace is collapsed because a plain text body arrives hard wrapped and a preview with the /// original line breaks in it renders as one long line with gaps in the middle. The cut is on a /// word boundary when there is one within reach of the limit, because a snippet ending mid-word /// reads as a rendering fault rather than as a preview. pub fn preview(text: &str) -> String { const LIMIT: usize = 200; let collapsed = text.split_whitespace().collect::>().join(" "); if collapsed.chars().count() <= LIMIT { return collapsed; } let cut = collapsed .char_indices() .nth(LIMIT) .map(|(at, _)| at) .unwrap_or(collapsed.len()); let head = &collapsed[..cut]; match head.rfind(' ') { // Only when the last space is near enough that trimming to it is a word boundary rather // than throwing away half the preview. Some(space) if space > cut * 3 / 4 => head[..space].to_string(), _ => head.to_string(), } } /// The stored form of a surface. A word rather than a flag, because a third answer is a plausible /// thing to want later and a boolean column that has to grow one is a migration. fn surface_name(surface: crate::dto::Surface) -> &'static str { match surface { crate::dto::Surface::Theme => "theme", crate::dto::Surface::Paper => "paper", } } pub fn store_body( conn: &Connection, message_id: &str, raw: &[u8], options: &RenderOptions, ) -> Result<(), String> { let rendered = mime::render(raw, options); let now = now_ms(); match rendered { Ok(body) => { conn.execute( "INSERT INTO bodies (message_id, raw, html, quoted_html, text, is_html, surface, trackers, blocked_images, render_version, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(message_id) DO UPDATE SET raw = excluded.raw, html = excluded.html, quoted_html = excluded.quoted_html, text = excluded.text, is_html = excluded.is_html, surface = excluded.surface, trackers = excluded.trackers, blocked_images = excluded.blocked_images, render_version = excluded.render_version, fetched_at = excluded.fetched_at", params![ message_id, raw, body.html, body.quoted_html, body.text, body.is_html as i64, surface_name(body.surface), json(&body.trackers), body.blocked_images as i64, mime::RENDER_VERSION, now, ], ) .map_err(|e| e.to_string())?; store_attachments(conn, message_id, &body.attachments)?; // The snippet is only written when there is not one already. Gmail sends its own with // the metadata and that one is better: it is what Gmail itself shows, so a thread does // not change its preview line the first time somebody opens it. A provider with no // snippet of its own, which is every IMAP server, gets one from the body instead, and // this is the only moment the text exists to take it from. conn.execute( "UPDATE messages SET has_attachment = ?2, snippet = CASE WHEN snippet = '' THEN ?3 ELSE snippet END WHERE id = ?1", params![ message_id, body.attachments.iter().any(|a| !a.inline) as i64, preview(body.text.as_deref().unwrap_or_default()) ], ) .map_err(|e| e.to_string())?; if let Some(thread_id) = thread_id_of(conn, message_id)? { refresh_thread(conn, &thread_id)?; } } Err(_) => { conn.execute( "INSERT INTO bodies (message_id, raw, html, render_version, fetched_at) VALUES (?1, ?2, '', 0, ?3) ON CONFLICT(message_id) DO UPDATE SET raw = excluded.raw, render_version = 0, fetched_at = excluded.fetched_at", params![message_id, raw, now], ) .map_err(|e| e.to_string())?; } } super::fts::index(conn, message_id) } /// Renders again from the bytes already held, for a body whose cached render was made by an older /// sanitiser or by no sanitiser at all. pub fn rerender( conn: &Connection, message_id: &str, options: &RenderOptions, ) -> Result { let raw: Option> = conn .query_row( "SELECT raw FROM bodies WHERE message_id = ?1 AND render_version <> ?2 AND raw IS NOT NULL", params![message_id, mime::RENDER_VERSION], |row| row.get(0), ) .optional() .map_err(|e| e.to_string())?; let Some(raw) = raw else { return Ok(false) }; store_body(conn, message_id, &raw, options)?; Ok(true) } pub fn store_attachments( conn: &Connection, message_id: &str, attachments: &[mime::RenderedAttachment], ) -> Result<(), String> { conn.execute("DELETE FROM attachments WHERE message_id = ?1", [message_id]) .map_err(|e| e.to_string())?; for part in attachments { conn.execute( "INSERT INTO attachments (id, message_id, part_id, filename, mime_type, size, inline, content_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(id) DO UPDATE SET filename = excluded.filename, mime_type = excluded.mime_type, size = excluded.size, inline = excluded.inline, content_id = excluded.content_id", params![ format!("{message_id}:{}", part.part_id), message_id, part.part_id, part.filename, part.mime_type, part.size as i64, part.inline as i64, part.content_id, ], ) .map_err(|e| e.to_string())?; } Ok(()) } /// Notes where the cache put an attachment's bytes. pub fn attachment_cached( conn: &Connection, id: &str, path: &str, at: i64, ) -> Result<(), String> { conn.execute( "UPDATE attachments SET cached_path = ?2, cached_at = ?3 WHERE id = ?1", params![id, path, at], ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Forgets a cached file. The row stays: an attachment that is no longer on the disk is still an /// attachment, and `storage_used` counts what the cache is holding rather than what a message has. pub fn attachment_uncached(conn: &Connection, id: &str) -> Result<(), String> { conn.execute( "UPDATE attachments SET cached_path = NULL, cached_at = NULL WHERE id = ?1", [id], ) .map(|_| ()) .map_err(|e| e.to_string()) } pub fn delete_message(conn: &Connection, id: &str) -> Result, String> { let thread_id = thread_id_of(conn, id)?; super::fts::remove(conn, id)?; for sql in [ "DELETE FROM bodies WHERE message_id = ?1", "DELETE FROM attachments WHERE message_id = ?1", "DELETE FROM messages WHERE id = ?1", ] { conn.execute(sql, [id]).map_err(|e| e.to_string())?; } if let Some(thread_id) = &thread_id { refresh_thread(conn, thread_id)?; } Ok(thread_id) } // --------------------------------------------------------------------------------------------- // The outbox // --------------------------------------------------------------------------------------------- pub const OP_FLAGS: &str = "flags"; pub const OP_LABELS: &str = "labels"; /// The send pipeline is a later package. A send is an outbox row like any other, with `hold_until` /// carrying its undo delay, and `sync::outbox` refuses it with a readable error until that package /// lands rather than pretending it went. pub const OP_SEND: &str = "send"; #[derive(Debug, Clone, Default)] pub struct OutboxRow { pub id: String, pub op: String, pub payload: String, pub thread_key: Option, pub hold_until: i64, pub attempts: i64, pub created_at: i64, pub last_error: Option, } pub fn enqueue(conn: &Connection, row: &OutboxRow) -> Result { let id = if row.id.is_empty() { fresh_id("out") } else { row.id.clone() }; conn.execute( "INSERT INTO outbox (id, op, payload, thread_key, hold_until, attempts, created_at, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, hold_until = excluded.hold_until, last_error = excluded.last_error", params![ id, row.op, row.payload, row.thread_key, row.hold_until, row.attempts, if row.created_at == 0 { now_ms() } else { row.created_at }, row.last_error, ], ) .map_err(|e| e.to_string())?; Ok(id) } pub fn outbox_rows(conn: &Connection, limit: usize) -> Result, String> { let mut stmt = conn .prepare( "SELECT id, op, payload, thread_key, hold_until, attempts, created_at, last_error FROM outbox ORDER BY created_at, id LIMIT ?1", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([limit as i64], |row| { Ok(OutboxRow { id: row.get(0)?, op: row.get(1)?, payload: row.get(2)?, thread_key: row.get(3)?, hold_until: row.get(4)?, attempts: row.get(5)?, created_at: row.get(6)?, last_error: row.get(7)?, }) }) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } /// The newest row of a kind that has not been attempted and is not being held, which is the row a /// second change of the same kind coalesces into. pub fn coalescable(conn: &Connection, op: &str, now: i64) -> Result, String> { let mut stmt = conn .prepare( "SELECT id, op, payload, thread_key, hold_until, attempts, created_at, last_error FROM outbox WHERE op = ?1 AND attempts = 0 AND hold_until <= ?2 ORDER BY created_at DESC, id DESC LIMIT 1", ) .map_err(|e| e.to_string())?; let mut rows = stmt .query_map(params![op, now], |row| { Ok(OutboxRow { id: row.get(0)?, op: row.get(1)?, payload: row.get(2)?, thread_key: row.get(3)?, hold_until: row.get(4)?, attempts: row.get(5)?, created_at: row.get(6)?, last_error: row.get(7)?, }) }) .map_err(|e| e.to_string())?; match rows.next() { Some(row) => row.map(Some).map_err(|e| e.to_string()), None => Ok(None), } } pub fn dequeue(conn: &Connection, id: &str) -> Result<(), String> { conn.execute("DELETE FROM outbox WHERE id = ?1", [id]) .map(|_| ()) .map_err(|e| e.to_string()) } pub fn defer(conn: &Connection, id: &str, until: i64, error: &str) -> Result<(), String> { conn.execute( "UPDATE outbox SET attempts = attempts + 1, hold_until = ?2, last_error = ?3 WHERE id = ?1", params![id, until, error], ) .map(|_| ()) .map_err(|e| e.to_string()) } pub fn pending_writes(conn: &Connection) -> Result { count(conn, "SELECT COUNT(*) FROM outbox") } /// Throws the mirror away, leaving the state database untouched. The next sync fills it again. pub fn clear(conn: &Connection) -> Result<(), String> { conn.execute_batch( "DELETE FROM search; DELETE FROM bodies; DELETE FROM attachments; DELETE FROM messages; DELETE FROM threads; DELETE FROM correspondents; DELETE FROM meta WHERE key IN ('sync-cursor', 'last-sync', 'backfill-after', 'first-sync-done');", ) .map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_preview_collapses_the_hard_wrapping_a_plain_text_body_arrives_with() { let wrapped = "Hi Ana,\n\nThe piano lesson moved to five.\n See you then.\n"; assert_eq!( preview(wrapped), "Hi Ana, The piano lesson moved to five. See you then." ); assert_eq!(preview(""), ""); assert_eq!(preview(" \n\t "), ""); } #[test] fn a_long_preview_is_cut_on_a_word_boundary_when_one_is_within_reach() { let long = "alpha ".repeat(60); let cut = preview(&long); assert!(cut.chars().count() <= 200); // On a boundary, so the last word is whole rather than sliced in half. assert!(cut.ends_with("alpha")); assert!(!cut.ends_with(' ')); } #[test] fn a_preview_with_no_boundary_near_the_limit_is_cut_anyway_rather_than_returned_whole() { // One word longer than the limit, so there is nowhere good to cut and it is cut regardless. let unbroken = "x".repeat(400); assert_eq!(preview(&unbroken).chars().count(), 200); } #[test] fn a_preview_cuts_on_a_character_boundary_and_not_a_byte_one() { // Every character here is three bytes, so a byte index would panic or split one in half. let text = "\u{3042}".repeat(400); assert_eq!(preview(&text).chars().count(), 200); } }