Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/mail/host-service/src/contacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Who the person writes to, for the shell's approval rules ("recipients in
//! my contacts", ADR 0004 §8). Read-only, host-side, and only when the
//! person turned on "Use my contacts in approval rules": no app calls this.
//!
//! The known set is the person's own account addresses plus every address
//! they have sent mail to: the recipients this service sent for them
//! ([`SENT_TO_FILE`], per account) and the `To`/`Cc` of the messages in
//! each account's sent folder. It reads `accounts.json`, `folders-<id>.json`,
//! the sent folders' `box-<id>-….json` and [`SENT_TO_FILE`]; never the
//! secret store.

use crate::{mailbox_file, text, write_atomic, INBOX};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::path::Path;

/// Relative to `<host_dir>/mail`: `{"<account id>": ["ana@example.org", …]}`.
pub const SENT_TO_FILE: &str = "sent-to.json";

/// The addresses of the person's accounts and of everyone they sent mail
/// to, lowercased and sorted. `host_dir` is the one the Card runner hands
/// every host service (the mail lives in `<host_dir>/mail`).
pub fn known_addresses(host_dir: &Path) -> Vec<String> {
let dir = host_dir.join("mail");
let mut known = BTreeSet::new();
let accounts: Vec<Value> = read(&dir.join("accounts.json")).and_then(|v| v.as_array().cloned()).unwrap_or_default();
for account in &accounts {
add(&mut known, text(account, "address"));
let id = text(account, "id");
if id.is_empty() {
continue;
}
let folders = read(&dir.join(format!("folders-{id}.json"))).and_then(|v| v.as_array().cloned()).unwrap_or_default();
for folder in folders.iter().filter(|f| text(f, "role") == "sent") {
let name = text(folder, "id");
if name.is_empty() || name == INBOX {
continue;
}
let path = mailbox_file(&dir, id, name);
let messages = read(&path).and_then(|m| m["messages"].as_array().cloned()).unwrap_or_default();
for message in &messages {
for key in ["to", "cc"] {
add_header(&mut known, text(message, key));
}
}
}
}
if let Some(Value::Object(sent)) = read(&dir.join(SENT_TO_FILE)) {
for addresses in sent.values() {
for a in addresses.as_array().into_iter().flatten() {
add(&mut known, a.as_str().unwrap_or(""));
}
}
}
known.into_iter().collect()
}

/// After a send went out: remember its recipient under the account.
pub(crate) fn record_sent(mail_dir: &Path, account_id: &str, to: &str) {
let path = mail_dir.join(SENT_TO_FILE);
let mut sent = read(&path).filter(Value::is_object).unwrap_or_else(|| json!({}));
let mut recipients = BTreeSet::new();
add_header(&mut recipients, to);
if recipients.is_empty() {
return;
}
let list = sent[account_id].as_array().cloned().unwrap_or_default();
let mut all: BTreeSet<String> = list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
let before = all.len();
all.extend(recipients);
if all.len() != before {
sent[account_id] = json!(all.into_iter().collect::<Vec<_>>());
let _ = write_atomic(&path, &serde_json::to_vec_pretty(&sent).unwrap());
}
}

/// An account is forgotten: so are the people it sent to.
pub(crate) fn forget(mail_dir: &Path, account_id: &str) {
let path = mail_dir.join(SENT_TO_FILE);
if let Some(Value::Object(mut sent)) = read(&path) {
if sent.remove(account_id).is_some() {
let _ = write_atomic(&path, &serde_json::to_vec_pretty(&Value::Object(sent)).unwrap());
}
}
}

fn read(path: &Path) -> Option<Value> {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok())
}

fn add(known: &mut BTreeSet<String>, address: &str) {
let a = address.trim().to_lowercase();
if a.contains('@') && !a.contains(char::is_whitespace) {
known.insert(a);
}
}

/// "Ana <ana@example.org>, bo@example.org" → each address.
fn add_header(known: &mut BTreeSet<String>, header: &str) {
if header.trim().is_empty() {
return;
}
match mailparse::addrparse(header) {
Ok(list) => {
for addr in list.iter() {
match addr {
mailparse::MailAddr::Single(s) => add(known, &s.addr),
mailparse::MailAddr::Group(g) => g.addrs.iter().for_each(|s| add(known, &s.addr)),
}
}
}
Err(_) => header.split([',', ';']).for_each(|a| add(known, a)),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_sent_folder_names_whom_the_person_wrote_to() {
let host = std::env::temp_dir().join(format!("mail-contacts-{}", std::process::id()));
let dir = host.join("mail");
let _ = std::fs::remove_dir_all(&host);
std::fs::create_dir_all(&dir).unwrap();
let w = |p: std::path::PathBuf, v: Value| std::fs::write(p, v.to_string()).unwrap();
w(dir.join("accounts.json"), json!([{"id": "a1", "address": "Me@Example.com", "apps": ["os.mail"]}]));
w(dir.join("folders-a1.json"), json!([{"id": INBOX, "role": "inbox"}, {"id": "Sent Items", "role": "sent"}, {"id": "Junk", "role": "junk"}]));
w(mailbox_file(&dir, "a1", "Sent Items"), json!({"messages": [{"to": "Ana <ana@example.org>, bo@example.org", "cc": "chen@example.org"}]}));
// Senders in the inbox and junk are not contacts.
w(mailbox_file(&dir, "a1", INBOX), json!({"messages": [{"address": "stranger@example.net", "to": "me@example.com"}]}));
w(mailbox_file(&dir, "a1", "Junk"), json!({"messages": [{"to": "spam@example.net"}]}));
record_sent(&dir, "a1", "dee@example.org");
assert_eq!(known_addresses(&host), ["ana@example.org", "bo@example.org", "chen@example.org", "dee@example.org", "me@example.com"]);
forget(&dir, "a1");
assert!(!known_addresses(&host).contains(&"dee@example.org".to_string()));
let _ = std::fs::remove_dir_all(&host);
}
}
38 changes: 30 additions & 8 deletions apps/mail/host-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@
//! State lives under the host's own directory (`<host_dir>/mail`), outside
//! every app's jail: `accounts.json` (no passwords) and `box-<id>…json` (the
//! fetched mail). Passwords go to the platform's secret store ([`vault`]).
//!
//! The shell reads one more thing, host-side and only with the person's
//! consent: [`contacts::known_addresses`], their own addresses and the
//! people they sent mail to, for the approval rules' "recipients in my
//! contacts".
use octosense_appstore::services::{close_sheet_later, HostService, Replier, ServiceCall, ServiceHost};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

pub mod contacts;
pub mod vault;
mod html;
mod imap;
Expand Down Expand Up @@ -175,7 +181,7 @@ impl Transport for DemoTransport {
.map(|(uid, sender, address, subject, body, html)| {
json!({"id": &network::hash(uid)[..24], "uid": uid, "sender": sender, "address": address, "subject": subject,
"body": if body.is_empty() { "(No readable message body)" } else { body }, "preview": body, "html": html,
"time": "Sep 25", "date": "2026-09-25T09:00:00Z", "unread": folder == INBOX})
"to": if folder == INBOX { "me@example.com" } else { "noah@example.com" }, "time": "Sep 25", "date": "2026-09-25T09:00:00Z", "unread": folder == INBOX})
})
.collect();
seen.extend(messages.iter().map(|m| m["uid"].clone()));
Expand Down Expand Up @@ -258,13 +264,8 @@ impl Store {
Ok(account)
}

/// The inbox keeps the name older builds gave it.
fn mailbox_path(&self, id: &str, folder: &str) -> PathBuf {
if folder == INBOX {
self.dir.join(format!("box-{id}.json"))
} else {
self.dir.join(format!("box-{id}-{}.json", &network::hash(folder)[..12]))
}
mailbox_file(&self.dir, id, folder)
}

fn mailbox(&self, id: &str, folder: &str) -> Value {
Expand All @@ -289,6 +290,7 @@ impl Store {

fn forget(&self, id: &str) {
self.vault.remove(&self.dir, id);
contacts::forget(&self.dir, id);
if let Ok(entries) = std::fs::read_dir(&self.dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
Expand All @@ -300,6 +302,16 @@ impl Store {
}
}

/// Where one folder's fetched mail is kept; the inbox keeps the name older
/// builds gave it.
fn mailbox_file(dir: &Path, id: &str, folder: &str) -> PathBuf {
if folder == INBOX {
dir.join(format!("box-{id}.json"))
} else {
dir.join(format!("box-{id}-{}.json", &network::hash(folder)[..12]))
}
}

fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Cannot store mail: {e}"))?;
Expand Down Expand Up @@ -580,7 +592,13 @@ impl HostService for MailService {
"message_id": format!("<{now:x}@{domain}>"),
});
let transport = self.transport.clone();
work(move || reply.send(transport.send(&account, &draft)));
work(move || {
let sent = transport.send(&account, &draft);
if sent.is_ok() {
contacts::record_sent(&store.dir, &account_arg, text(&draft, "to"));
}
reply.send(sent)
});
}
other => reply.send(Err(format!("mail has no method {other:?}"))),
}
Expand Down Expand Up @@ -832,9 +850,13 @@ mod tests {

ask(&dir, "os.mail", "mail.send", json!({"account": id, "to": "alex@example.com", "subject": "Hi", "body": "Hello"}), false, &mut host).unwrap();
assert_eq!(fake.sent.lock().unwrap()[0]["to"], "alex@example.com");
// The approval rules' contacts: the account and whom it wrote to, no password.
assert_eq!(contacts::known_addresses(&dir), ["alex@example.com", "me@example.com"]);
assert!(!std::fs::read_to_string(dir.join("mail").join(contacts::SENT_TO_FILE)).unwrap().contains("s3cret"));

ask(&dir, "os.mail", "mail.remove_account", json!({"account": id}), false, &mut host).unwrap();
assert!(!dir.join("mail/secrets").join(&id).exists(), "the last app out takes the password with it");
assert!(std::fs::read_dir(dir.join("mail")).unwrap().flatten().all(|e| !e.file_name().to_string_lossy().starts_with("box-")), "and its mail");
assert!(contacts::known_addresses(&dir).is_empty(), "and whom it wrote to");
}
}
2 changes: 1 addition & 1 deletion apps/mail/host-service/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ pub fn decode(raw: &[u8], uid: &str) -> Result<Value, String> {
};
Ok(
json!({"id":&hash(uid)[..24],"uid":uid,"sender":sender,"address":address,"subject":subject,
"message_id":header("Message-ID"),"reply_to":header("Reply-To"),"references":header("References"),
"to":header("To"),"cc":header("Cc"),"message_id":header("Message-ID"),"reply_to":header("Reply-To"),"references":header("References"),
"body":plain,"preview":plain.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(180).collect::<String>(),
"html":html,"inline_images":images,"attachment_items":attachments,"attachments":attachments.len(),
"date":date.to_rfc3339(),"time":date.format("%b %d").to_string(),"unread":true,"flagged":false,"archived":false,"source":"gmail"}),
Expand Down
Loading