Skip to main content

sioul_sync/
shield_ai.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The AI reading of a shielded address (docs/shield.md). Only when you allow
5//! it for that address: the subject and text of each new message in its inbox
6//! are sent once to Anthropic's API, with the question of `shield::ai_prompt`,
7//! and the answer (tone, topic, one neutral line) is kept by Message-ID. The
8//! key lives in the system keyring, under the service "sioul".
9
10use crate::SyncError;
11use sioul_core::config::Config;
12use sioul_core::shield::{self, AiCache};
13use std::time::Duration;
14
15/// A small, fast model: the question is short, the answer one line.
16pub const MODEL: &str = "claude-haiku-4-5-20251001";
17const ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
18/// Messages read per address and round; a burst of mail is read over the next rounds.
19const PER_ROUND: usize = 20;
20
21fn key_entry() -> Result<keyring::Entry, SyncError> {
22    keyring::Entry::new("sioul", "Anthropic API key").map_err(|e| SyncError::Keyring(e.to_string()))
23}
24
25/// The key kept for Anthropic's API, if any.
26pub fn api_key() -> Option<String> {
27    if crate::secret::testing() {
28        return None;
29    }
30    key_entry().ok()?.get_password().ok().filter(|k| !k.trim().is_empty())
31}
32
33/// Keeps the key, replacing any older one; an empty key forgets it.
34pub fn save_api_key(key: &str) -> Result<(), SyncError> {
35    if crate::secret::testing() {
36        return Ok(());
37    }
38    let entry = key_entry()?;
39    if key.trim().is_empty() {
40        return match entry.delete_credential() {
41            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
42            Err(e) => Err(SyncError::Keyring(e.to_string())),
43        };
44    }
45    entry.set_password(key.trim()).map_err(|e| SyncError::Keyring(e.to_string()))
46}
47
48/// What a round of reading did.
49#[derive(Debug, Default, PartialEq, Eq)]
50pub struct Read {
51    /// Messages read and kept.
52    pub read: usize,
53    /// Messages still to read, for the next rounds.
54    pub left: usize,
55}
56
57/// Reads the new mail of every shielded address that allows the AI, up to
58/// `PER_ROUND` messages each, newest first. Nothing is sent without a key.
59pub fn read_new(config: &Config) -> Result<Read, SyncError> {
60    if !config.accounts.iter().any(|a| a.ai_reads()) {
61        return Ok(Read::default());
62    }
63    let Some(key) = api_key() else { return Ok(Read::default()) };
64    read_with(config, ENDPOINT, &key)
65}
66
67/// `read_new`, its questions sent to `endpoint` with `key`.
68fn read_with(config: &Config, endpoint: &str, key: &str) -> Result<Read, SyncError> {
69    // Never a work or school Microsoft account's mail (`Account::ai_reads`).
70    let accounts: Vec<_> = config.accounts.iter().filter(|a| a.ai_reads()).collect();
71    // No redirect followed: the key is a header of its own, which would go along to any other address.
72    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(60))).http_status_as_error(false).max_redirects(0).build().into();
73    let mut done = Read::default();
74    for account in accounts {
75        let mut cache = AiCache::load(&account.id);
76        // Neither answered, nor sent twice for answers not in the form asked (`AiCache::wants`).
77        let mut waiting: Vec<_> = sioul_core::maildir::read_messages(&account.maildir_path())
78            .into_iter()
79            .filter_map(|card| Some((shield::ai_key(&card)?, card)))
80            .filter(|(id, _)| cache.wants(id))
81            .collect();
82        waiting.sort_by_key(|(_, card)| std::cmp::Reverse(card.date.unwrap_or(0)));
83        done.left += waiting.len().saturating_sub(PER_ROUND);
84        for (id, card) in waiting.into_iter().take(PER_ROUND) {
85            match ask(&agent, endpoint, key, &shield::ai_prompt(&card.subject, &card.excerpt)) {
86                Ok(Some(assessment)) => {
87                    cache.malformed.remove(&id);
88                    cache.messages.insert(id, assessment);
89                    // Kept at once: an answer is never asked for twice.
90                    cache.save(&account.id).map_err(SyncError::Disk)?;
91                    done.read += 1;
92                }
93                // An answer not in the form asked: the word lists keep judging
94                // that one. Marked, kept at once: asked once more at most.
95                Ok(None) => {
96                    *cache.malformed.entry(id).or_default() += 1;
97                    cache.save(&account.id).map_err(SyncError::Disk)?;
98                }
99                Err(e) => return Err(e),
100            }
101        }
102    }
103    Ok(done)
104}
105
106/// One question; None when the answer is not the JSON asked for.
107fn ask(agent: &ureq::Agent, endpoint: &str, key: &str, prompt: &str) -> Result<Option<shield::Assessment>, SyncError> {
108    let body = serde_json::json!({
109        "model": MODEL,
110        "max_tokens": 200,
111        "messages": [{ "role": "user", "content": prompt }],
112    });
113    let mut response = agent
114        .post(endpoint)
115        .header("x-api-key", key)
116        .header("anthropic-version", "2023-06-01")
117        .header("content-type", "application/json")
118        .header("User-Agent", "Sioul")
119        .send(body.to_string())
120        .map_err(|e| SyncError::Network(e.to_string()))?;
121    let status = response.status().as_u16();
122    let text = response.body_mut().read_to_string().unwrap_or_default();
123    match status {
124        200 => {}
125        401 | 403 => return Err(SyncError::Login(format!("Anthropic API: {status}"))),
126        // Busy or rate-limited: the next round asks again.
127        _ => return Err(SyncError::Server(format!("Anthropic API: {status}"))),
128    }
129    let answer: serde_json::Value = serde_json::from_str(&text).map_err(|e| SyncError::Server(e.to_string()))?;
130    let said: String = answer["content"].as_array().into_iter().flatten().filter_map(|part| part["text"].as_str()).collect();
131    Ok(shield::read_ai_answer(&said))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::io::{Read as _, Write as _};
138
139    /// A one-shot server that answers `status` and `body`, and hands back the request it got.
140    fn stand_in(status: u16, body: &'static str) -> (String, std::thread::JoinHandle<String>) {
141        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
142        let url = format!("http://{}/v1/messages", listener.local_addr().unwrap());
143        let served = std::thread::spawn(move || {
144            let (mut stream, _) = listener.accept().unwrap();
145            stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
146            let mut request = Vec::new();
147            let mut chunk = [0u8; 4096];
148            // Headers, then as many bytes as Content-Length says.
149            loop {
150                let n = stream.read(&mut chunk).unwrap();
151                request.extend_from_slice(&chunk[..n]);
152                let text = String::from_utf8_lossy(&request).to_string();
153                if let Some(end) = text.find("\r\n\r\n") {
154                    let length = text.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").map(|v| v.trim().parse::<usize>().unwrap())).unwrap_or(0);
155                    if request.len() >= end + 4 + length {
156                        break;
157                    }
158                }
159                if n == 0 {
160                    break;
161                }
162            }
163            write!(stream, "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
164            String::from_utf8_lossy(&request).to_string()
165        });
166        (url, served)
167    }
168
169    fn agent() -> ureq::Agent {
170        ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(10))).http_status_as_error(false).build().into()
171    }
172
173    #[test]
174    fn an_answer_is_read() {
175        let (url, served) = stand_in(200, r#"{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"{\"tone\": \"calm\", \"topic\": \"work\", \"summary\": \"A three-month mission, asks your rate.\"}"}],"model":"claude-haiku-4-5-20251001","stop_reason":"end_turn"}"#);
176        let assessment = ask(&agent(), &url, "test-key", "Subject: Mission").unwrap().unwrap();
177        assert_eq!((assessment.tone, assessment.topic), (shield::Tone::Calm, shield::Topic::Work));
178        assert_eq!(assessment.summary, "A three-month mission, asks your rate.");
179        assert!(assessment.by_ai);
180        let request = served.join().unwrap();
181        assert!(request.contains("x-api-key: test-key"), "{request}");
182        assert!(request.contains("anthropic-version: 2023-06-01"));
183        assert!(request.contains(MODEL));
184    }
185
186    /// A stand-in that answers every request with `body`, `times` requests at
187    /// most, and counts them.
188    fn stand_in_counting(body: &'static str, times: usize) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
189        use std::sync::atomic::{AtomicUsize, Ordering};
190        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
191        let url = format!("http://{}/v1/messages", listener.local_addr().unwrap());
192        let count = std::sync::Arc::new(AtomicUsize::new(0));
193        let counted = std::sync::Arc::clone(&count);
194        std::thread::spawn(move || {
195            for _ in 0..times {
196                let Ok((mut stream, _)) = listener.accept() else { return };
197                stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
198                let mut request = Vec::new();
199                let mut chunk = [0u8; 4096];
200                loop {
201                    let n = stream.read(&mut chunk).unwrap_or(0);
202                    request.extend_from_slice(&chunk[..n]);
203                    let text = String::from_utf8_lossy(&request).to_string();
204                    let length = text.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").map(|v| v.trim().parse::<usize>().unwrap())).unwrap_or(0);
205                    if n == 0 || text.find("\r\n\r\n").is_some_and(|end| request.len() >= end + 4 + length) {
206                        break;
207                    }
208                }
209                counted.fetch_add(1, Ordering::SeqCst);
210                let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
211            }
212        });
213        (url, count)
214    }
215
216    /// An answer not in the form asked is marked: the message is sent once
217    /// more at most, never at every round. A stand-in answers; nothing goes to Anthropic.
218    #[test]
219    fn a_malformed_answer_is_asked_once_more_at_most() {
220        use std::sync::atomic::Ordering;
221        let home = crate::dav::stand_in::home();
222        let mut account = sioul_core::config::Account::imap("shield-ai-tester", "public@example.org", "localhost", 993, sioul_core::config::Security::Tls, None);
223        account.shield = true;
224        account.shield_ai = true;
225        let mail = home.join(format!("mail-shield-ai-{}", std::process::id()));
226        account.maildir = Some(mail.display().to_string());
227        std::fs::create_dir_all(mail.join("cur")).unwrap();
228        std::fs::write(mail.join("cur").join(format!("1759300001.U1-1.test{}2,S", sioul_core::maildir::INFO)), "From: Someone <someone@example.net>\r\nTo: public@example.org\r\nSubject: About your software\r\nMessage-ID: <malformed-1@example.net>\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nA question about your software.\r\n").unwrap();
229        let mut config = Config::default();
230        config.accounts = vec![account.clone()];
231        let (url, asked) = stand_in_counting(r#"{"content":[{"type":"text","text":"I cannot help with that."}]}"#, 3);
232        for _ in 0..4 {
233            assert_eq!(read_with(&config, &url, "test-key").unwrap(), Read { read: 0, left: 0 });
234        }
235        assert_eq!(asked.load(Ordering::SeqCst), AiCache::TIMES as usize, "sent twice, then never again");
236        let cache = AiCache::load(&account.id);
237        assert!(cache.messages.is_empty() && cache.malformed.values().eq([&AiCache::TIMES]), "{cache:?}");
238        // An answer in the form asked replaces the mark.
239        let mut kept = cache.clone();
240        kept.malformed.clear();
241        kept.save(&account.id).unwrap();
242        let (url, asked) = stand_in_counting(r#"{"content":[{"type":"text","text":"{\"tone\": \"calm\", \"topic\": \"support\", \"summary\": \"Asks about the software.\"}"}]}"#, 2);
243        assert_eq!(read_with(&config, &url, "test-key").unwrap().read, 1);
244        let cache = AiCache::load(&account.id);
245        assert!(cache.malformed.is_empty() && cache.messages.len() == 1 && asked.load(Ordering::SeqCst) == 1, "{cache:?}");
246        let _ = std::fs::remove_dir_all(&mail);
247    }
248
249    #[test]
250    fn a_refused_key_stops() {
251        let (url, served) = stand_in(401, r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#);
252        assert!(matches!(ask(&agent(), &url, "bad", "Subject: x"), Err(SyncError::Login(_))));
253        served.join().unwrap();
254        // Another form of answer: left to the word lists.
255        let (url, served) = stand_in(200, r#"{"content":[{"type":"text","text":"I cannot help with that."}]}"#);
256        assert_eq!(ask(&agent(), &url, "k", "Subject: x").unwrap(), None);
257        served.join().unwrap();
258    }
259}