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