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 if crate::secret::testing() {
28 return None;
29 }
30 key_entry().ok()?.get_password().ok().filter(|k| !k.trim().is_empty())
31}
32
33pub 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#[derive(Debug, Default, PartialEq, Eq)]
50pub struct Read {
51 pub read: usize,
53 pub left: usize,
55}
56
57pub 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
67fn read_with(config: &Config, endpoint: &str, key: &str) -> Result<Read, SyncError> {
69 let accounts: Vec<_> = config.accounts.iter().filter(|a| a.ai_reads()).collect();
71 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 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 cache.save(&account.id).map_err(SyncError::Disk)?;
91 done.read += 1;
92 }
93 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
106fn 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 _ => 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 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 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 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 #[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 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 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}