1use crate::SyncError;
11use sioul_core::config::Config;
12use sioul_core::shield::{self, AiCache};
13use std::time::Duration;
14
15pub const MODEL: &str = "claude-haiku-4-5-20251001";
17const ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
18const 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
25pub fn api_key() -> Option<String> {
27 key_entry().ok()?.get_password().ok().filter(|k| !k.trim().is_empty())
28}
29
30pub 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#[derive(Debug, Default, PartialEq, Eq)]
44pub struct Read {
45 pub read: usize,
47 pub left: usize,
49}
50
51pub 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
61fn 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 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 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 cache.save(&account.id).map_err(SyncError::Disk)?;
84 done.read += 1;
85 }
86 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
99fn 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 _ => 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 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 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 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 #[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 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 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}