1use crate::{Session, load_store, print_right_now};
13use clap::{Subcommand, ValueEnum};
14use jiff::Zoned;
15use sioul_core::config::{self, Account, AccountKind, Config, DEFAULT_SYNC_DAYS, Priority, Security};
16use sioul_core::porch::{self, KnownSenders, Triaged};
17use sioul_core::view;
18use sioul_sync::discover::MicrosoftKind;
19use sioul_sync::{Control, Found, FoundBy, Learned, Report, SyncError, microsoft, notify, secret};
20use std::path::PathBuf;
21use std::sync::atomic::AtomicBool;
22use std::sync::{Arc, mpsc};
23
24#[derive(Subcommand)]
25pub(crate) enum AccountCommand {
26 Add {
28 address: String,
29 #[arg(long)]
31 id: Option<String>,
32 #[arg(long)]
34 host: Option<String>,
35 #[arg(long)]
36 port: Option<u16>,
37 #[arg(long, value_enum)]
38 security: Option<SecurityArg>,
39 #[arg(long)]
41 username: Option<String>,
42 #[arg(long)]
44 days: Option<u32>,
45 #[arg(long, value_enum)]
48 auth: Option<AuthArg>,
49 #[arg(long)]
51 client_id: Option<String>,
52 #[arg(long)]
54 tenant: Option<String>,
55 },
56 SignIn {
58 id: String,
59 #[arg(long)]
61 client_id: Option<String>,
62 #[arg(long)]
64 tenant: Option<String>,
65 },
66 Portal { id: String, url: String },
68 List,
70 Test { id: String },
72 Password { id: String },
74 Remove {
77 id: String,
78 #[arg(long, conflicts_with = "delete_mail")]
80 keep_mail: bool,
81 #[arg(long)]
83 delete_mail: bool,
84 },
85 Priority {
88 id: String,
89 #[arg(value_enum)]
90 priority: PriorityArg,
91 },
92}
93
94#[derive(Clone, Copy, ValueEnum)]
95pub(crate) enum PriorityArg {
96 Above,
97 Average,
98 Below,
99}
100
101#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
103pub(crate) enum AuthArg {
104 Microsoft,
105}
106
107#[derive(Clone, Copy, ValueEnum)]
108pub(crate) enum SecurityArg {
109 Tls,
110 Starttls,
111}
112
113impl From<SecurityArg> for Security {
114 fn from(value: SecurityArg) -> Security {
115 match value {
116 SecurityArg::Tls => Security::Tls,
117 SecurityArg::Starttls => Security::Starttls,
118 }
119 }
120}
121
122pub(crate) fn run(s: &Session, command: AccountCommand) -> Result<(), String> {
123 match command {
124 AccountCommand::Add { address, id, host, port, security, username, days, auth, client_id, tenant } => {
125 let wanted = Wanted { host, port, security: security.map(Security::from), username, days, microsoft: auth == Some(AuthArg::Microsoft), client_id, tenant };
126 add(s, &address, id, wanted)
127 }
128 AccountCommand::SignIn { id, client_id, tenant } => sign_in_again(s, &id, client_id.as_deref().unwrap_or(""), tenant.as_deref().unwrap_or("")),
129 AccountCommand::Portal { id, url } => portal(s, &id, &url),
130 AccountCommand::List => list(s),
131 AccountCommand::Test { id } => test(s, &id),
132 AccountCommand::Password { id } => password(s, &id),
133 AccountCommand::Remove { id, keep_mail, delete_mail } => remove(s, &id, (keep_mail || delete_mail).then_some(delete_mail)),
134 AccountCommand::Priority { id, priority } => {
135 let priority = match priority {
136 PriorityArg::Above => Priority::Above,
137 PriorityArg::Average => Priority::Average,
138 PriorityArg::Below => Priority::Below,
139 };
140 set_priority(s, &id, priority)
141 }
142 }
143}
144
145struct Wanted {
147 host: Option<String>,
148 port: Option<u16>,
149 security: Option<Security>,
150 username: Option<String>,
151 days: Option<u32>,
152 microsoft: bool,
154 client_id: Option<String>,
156 tenant: Option<String>,
157}
158
159fn add(s: &Session, address: &str, id: Option<String>, wanted: Wanted) -> Result<(), String> {
160 let address = address.trim().to_ascii_lowercase();
161 let mail = |a: &&Account| a.address.as_deref() == Some(address.as_str()) && matches!(a.kind, AccountKind::Imap | AccountKind::Jmap);
163 if let Some(existing) = s.config.every_account().find(mail) {
164 return Err(s.say("account-address-exists", &[("address", address.clone()), ("id", existing.id.clone())]));
165 }
166 let id = id.unwrap_or_else(|| s.config.free_id(&address));
167 if s.config.every_account().any(|a| a.id == id) {
168 return Err(s.say("account-exists", &[("id", id)]));
169 }
170 let found = match &wanted.host {
171 Some(host) => Found { host: host.clone(), port: 0, security: Security::Tls, username: address.clone(), by: sioul_sync::FoundBy::Provider, smtp: None },
173 None => {
174 let found = sioul_sync::discover(&address).map_err(|e| e.sentence(&s.tr, &id))?;
175 println!("{}", s.say("account-found", &[("server", server_line(s, &found.host, found.port, found.security)), ("by", s.tr.text(found.by.message_id(), None))]));
176 found
177 }
178 };
179 let microsoft = match found.by {
181 FoundBy::Microsoft(kind) => Some(kind),
182 _ if wanted.microsoft || microsoft::is_imap_host(&found.host) => Some(MicrosoftKind::Unknown),
183 _ => None,
184 };
185 if let Some(kind) = microsoft {
186 let mut account = Account::imap(&id, &address, microsoft::IMAP_HOST, 993, Security::Tls, None);
187 account.sync_days = wanted.days;
188 let account = sign_in_with_microsoft(s, account, kind, wanted.client_id.as_deref().unwrap_or(""), wanted.tenant.as_deref().unwrap_or(""))?;
189 config::add_imap_account(&s.config_path, &account)?;
190 config::set_sign_in(&s.config_path, &account)?;
191 println!("{}", s.say("account-added", &[("id", id.clone()), ("path", account.maildir_path().display().to_string())]));
192 println!("{}", s.tr.text("ui-syncing", None));
193 let report = sioul_sync::sync(&account, "").map_err(|e| e.sentence(&s.tr, &id))?;
194 say_report(s, &account, &report);
195 learn_provider(s, &account, true);
196 return Ok(());
197 }
198 let security = wanted.security.unwrap_or(found.security);
199 let port = wanted.port.or((found.port != 0).then_some(found.port)).unwrap_or(if security == Security::Tls { 993 } else { 143 });
200 let login = wanted.username.clone().unwrap_or(found.username);
201 let mut account = Account::imap(&id, &address, &found.host, port, security, Some(&login));
202 account.sync_days = wanted.days;
203 if wanted.host.is_some() {
204 println!("{}", server_line(s, &found.host, port, security));
205 }
206 if account.username.is_some() {
207 println!("{}", s.say("account-login", &[("login", crate::one_line(&login))]));
208 }
209 let google = found.by == sioul_sync::FoundBy::Google || found.host.eq_ignore_ascii_case(sioul_sync::discover::GOOGLE_IMAP);
211 let hint = if google { "account-gmail-hint" } else { "account-app-password-hint" };
212 println!("{}", s.tr.text(hint, None));
213 let password = sioul_sync::tidy_password(&found.host, &prompt_password(s, &address)?);
214 let messages = sioul_sync::test(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
215 let mut connected = s.tr.counted(messages as usize);
216 connected.set("account", id.clone());
217 println!("{}", s.tr.text("account-connected", Some(&connected)));
218 secret::save(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
219 config::add_imap_account(&s.config_path, &account)?;
220 println!("{}", s.say("account-added", &[("id", id.clone()), ("path", account.maildir_path().display().to_string())]));
221 println!("{}", s.tr.text("ui-syncing", None));
222 let report = sioul_sync::sync(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
223 say_report(s, &account, &report);
224 learn_provider(s, &account, true);
225 Ok(())
226}
227
228fn sign_in_with_microsoft(s: &Session, account: Account, kind: MicrosoftKind, client_id: &str, tenant: &str) -> Result<Account, String> {
238 let address = account.address.clone().unwrap_or_default();
239 let label = address.clone();
240 let login = account.login().unwrap_or(&address).to_string();
241 let client = microsoft::app_for(&login, client_id).map_err(|e| e.sentence(&s.tr, &label))?.ok_or_else(|| s.tr.text("microsoft-cli-no-app", None))?;
242 let authority = microsoft::authority_for(&login, kind, tenant).map_err(|e| e.sentence(&s.tr, &label))?;
243 println!("{}", s.tr.text("microsoft-cli-open", None));
244 let stop = AtomicBool::new(false);
245 let taken: Vec<String> = s.config.every_account().filter(|a| a.is_microsoft() && a.id != account.id).filter_map(|a| a.login().map(|l| l.trim().to_lowercase())).collect();
247 let signed = microsoft::sign_in(account, &client, &authority, &taken, |url| println!("\n{url}\n"), &stop).and_then(|signed| {
248 let yes = match signed.other_login.as_deref() {
250 Some(other) => ask_no(s, &s.say("microsoft-other-login-question", &[("address", crate::one_line(&address)), ("login", crate::one_line(other))])).unwrap_or(false),
252 None => true,
253 };
254 signed.answer(yes)
255 });
256 match signed {
257 Ok((account, grant)) => {
258 let kind = if grant.personal { "account-signed-in-microsoft-personal" } else { "account-signed-in-microsoft-work" };
259 println!("{}", s.say("microsoft-cli-signed-in", &[("account", label), ("how", s.tr.text(kind, None))]));
260 Ok(account)
261 }
262 Err(e) => {
263 let said = e.sentence(&s.tr, &label);
264 let organisation = matches!(&e, SyncError::Login(d) if d == microsoft::ADMIN) || matches!(&e, SyncError::Said(id, _) if *id == microsoft::DECLINED_WORK);
265 if organisation {
266 let domain = address.rsplit('@').next().unwrap_or("");
267 return Err(format!("{said}\n\n{}", s.say("microsoft-admin-steps", &[("link", microsoft::admin_consent_url(&client, domain))])));
268 }
269 Err(said)
270 }
271 }
272}
273
274fn sign_in_again(s: &Session, id: &str, client_id: &str, tenant: &str) -> Result<(), String> {
278 let account = s.config.every_account().find(|a| a.id == id && a.kind == AccountKind::Imap).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))?.clone();
279 let at_microsoft = account.host.as_deref().is_some_and(microsoft::is_imap_host);
280 if !account.is_microsoft() && !at_microsoft {
281 return Err(s.say("microsoft-cli-not-microsoft", &[("id", id.to_string())]));
282 }
283 let kind = if account.is_microsoft() && !account.is_microsoft_work() { MicrosoftKind::Personal } else if account.is_microsoft() { MicrosoftKind::Work } else { MicrosoftKind::Unknown };
284 let was_password = !account.is_microsoft();
285 let old = account.clone();
286 let account = sign_in_with_microsoft(s, account, kind, client_id, tenant)?;
287 config::set_sign_in(&s.config_path, &account)?;
288 if was_password {
290 let _ = secret::forget(&old);
291 }
292 println!("{}", s.say("microsoft-cli-again", &[("account", id.to_string())]));
293 Ok(())
294}
295
296fn server_line(s: &Session, host: &str, port: u16, security: Security) -> String {
297 s.say(
298 "account-server",
299 &[("host", crate::one_line(host)), ("port", port.to_string()), ("security", s.tr.text(&format!("security-{}", security.as_str()), None))],
300 )
301}
302
303pub(crate) fn prompt_password(s: &Session, address: &str) -> Result<String, String> {
304 #[cfg(feature = "insecure-test-tls")]
306 if let Ok(password) = std::env::var("SIOUL_TEST_PASSWORD") {
307 return Ok(password);
308 }
309 let prompt = s.say("account-password-prompt", &[("address", address.to_string())]);
310 let password = rpassword::prompt_password(format!("{prompt} ")).map_err(|e| e.to_string())?;
311 Ok(password.trim_end_matches(['\r', '\n']).to_string())
312}
313
314fn portal(s: &Session, id: &str, url: &str) -> Result<(), String> {
315 if s.config.site(id).is_some() || s.config.account(id).is_some() {
317 return Err(s.say("account-exists", &[("id", id.to_string())]));
318 }
319 if !url.starts_with("https://") {
320 return Err(s.tr.text("site-https-only", None));
321 }
322 config::add_portal(&s.config_path, id, url)?;
323 println!("{}", s.say("account-portal-added", &[("id", id.to_string()), ("url", url.to_string())]));
324 Ok(())
325}
326
327fn list(s: &Session) -> Result<(), String> {
328 if s.config.accounts.is_empty() {
329 println!("{}", s.tr.text("sync-nothing", None));
330 return Ok(());
331 }
332 for (account, shown) in s.config.accounts.iter().zip(view::accounts(&s.config, &s.tr, &Default::default(), &Default::default())) {
333 let mut parts = vec![account.id.clone()];
334 parts.extend(account.address.clone());
335 if account.kind != AccountKind::Portal {
336 parts.push(secret_said(s, account, secret::password(account)));
337 }
338 if account.kind == AccountKind::Imap {
339 parts.push(s.tr.text(&format!("priority-{}", shown.priority), None));
340 }
341 println!("{}", crate::one_line(&parts.join(" · ")));
342 let colon = if s.tr.language() == "fr" { "\u{202f}:" } else { ":" };
344 let mut indent = 4;
345 for row in shown.rows {
346 if row.label.is_empty() {
347 println!("{:indent$}{}", "", crate::one_line(&row.value));
348 } else {
349 println!(" {}{colon} {}", row.label, crate::one_line(&row.value));
350 indent = 4 + row.label.chars().count() + colon.chars().count() + 1;
351 }
352 }
353 }
354 Ok(())
355}
356
357fn secret_said(s: &Session, account: &Account, found: Result<String, SyncError>) -> String {
361 match found {
362 Ok(_) if account.is_microsoft() => s.tr.text("account-signed-in-microsoft", None),
363 Ok(_) if sioul_sync::sasl::OAuth::of(account).is_some() => s.tr.text("account-signed-in-google", None),
364 Ok(_) => s.tr.text("account-has-password", None),
365 Err(SyncError::NoPassword) => s.say("account-no-password", &[("id", account.id.clone())]),
366 Err(e) => e.sentence(&s.tr, &account.id),
367 }
368}
369
370fn mail_account<'a>(s: &'a Session, id: &str) -> Result<&'a Account, String> {
371 s.config.account(id).filter(|a| a.syncs()).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))
372}
373
374fn test(s: &Session, id: &str) -> Result<(), String> {
375 let account = mail_account(s, id)?;
376 let messages = secret::password(account).and_then(|p| sioul_sync::test(account, &p)).map_err(|e| e.sentence(&s.tr, id))?;
377 println!("{}", s.tr.text("account-connected", Some(&s.tr.counted(messages as usize))));
378 Ok(())
379}
380
381fn password(s: &Session, id: &str) -> Result<(), String> {
382 let account = mail_account(s, id)?;
383 if account.is_microsoft() || account.host.as_deref().is_some_and(microsoft::is_imap_host) {
385 return Err(s.say("microsoft-cli-no-password", &[("id", id.to_string())]));
386 }
387 let typed = prompt_password(s, account.address.as_deref().unwrap_or(id))?;
388 let password = sioul_sync::tidy_password(account.host.as_deref().unwrap_or(""), &typed);
389 let messages = sioul_sync::test(account, &password).map_err(|e| e.sentence(&s.tr, id))?;
390 println!("{}", s.tr.text("account-connected", Some(&s.tr.counted(messages as usize))));
391 secret::save(account, &password).map_err(|e| e.sentence(&s.tr, id))?;
392 println!("{}", s.tr.text("account-password-saved", None));
393 Ok(())
394}
395
396fn set_priority(s: &Session, id: &str, priority: Priority) -> Result<(), String> {
397 if s.config.account(id).is_none() {
398 return Err(s.say("account-unknown", &[("id", id.to_string())]));
399 }
400 config::set_priority(&s.config_path, id, priority)?;
401 let label = s.tr.text(&format!("priority-{}", priority.as_str()), None);
402 println!("{}", s.say("ui-priority-set", &[("id", id.to_string()), ("priority", label)]));
403 Ok(())
404}
405
406fn remove(s: &Session, id: &str, delete_mail: Option<bool>) -> Result<(), String> {
411 let account = s.config.every_account().find(|a| a.id == id).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))?;
413 let shared_password = s.config.every_account().any(|a| a.id != id && a.host == account.host && a.login() == account.login());
415 if account.is_microsoft() {
416 if delete_mail != Some(false) {
418 println!("{}", s.tr.text("microsoft-cli-remove-close", None));
419 }
420 let delete = delete_mail.map_or_else(|| ask_yes(s, &s.tr.text("ui-remove-microsoft-mail", None)), Ok)?;
421 let page = microsoft::forget(account.login().unwrap_or(id), !account.is_microsoft_work()).map_err(|e| e.sentence(&s.tr, id))?;
423 config::remove_account(&s.config_path, id)?;
424 if delete {
425 sioul_core::maildir::forget_account(account)?;
426 sioul_learn::Dirs::standard().forget_account(&account.id).map_err(|e| e.to_string())?;
427 println!("{}", s.say("account-removed-microsoft-mail", &[("id", id.to_string()), ("page", page.to_string())]));
428 } else {
429 println!("{}", s.say("account-removed-microsoft", &[("id", id.to_string()), ("path", account.maildir_path().display().to_string()), ("page", page.to_string())]));
430 }
431 return Ok(());
432 }
433 if account.auth.as_deref() == Some("google") {
434 let purpose = if account.is_dav() { sioul_sync::google::Purpose::Pim } else { sioul_sync::google::Purpose::Mail };
436 sioul_sync::google::revoke_for(purpose, account.address.as_deref().unwrap_or("")).map_err(|e| e.sentence(&s.tr, id))?;
437 } else if (account.syncs() || account.is_dav()) && !shared_password {
438 secret::forget(account).map_err(|e| e.sentence(&s.tr, id))?;
439 }
440 config::remove_account(&s.config_path, id)?;
441 println!("{}", s.say("account-removed", &[("id", id.to_string()), ("path", account.maildir_path().display().to_string())]));
442 Ok(())
443}
444
445fn ask_no(s: &Session, question: &str) -> Result<bool, String> {
449 use std::io::{BufRead, IsTerminal, Write};
450 if !std::io::stdin().is_terminal() {
451 return Ok(false);
452 }
453 print!("{question} {} ", s.tr.text("cli-no-yes", None));
454 std::io::stdout().flush().map_err(|e| e.to_string())?;
455 let mut line = String::new();
456 std::io::stdin().lock().read_line(&mut line).map_err(|e| e.to_string())?;
457 Ok(said_yes(&line))
458}
459
460fn said_yes(line: &str) -> bool {
462 let answer = line.trim().to_lowercase();
463 answer.starts_with('y') || answer.starts_with('o')
464}
465
466fn ask_yes(s: &Session, question: &str) -> Result<bool, String> {
469 use std::io::{BufRead, IsTerminal, Write};
470 if !std::io::stdin().is_terminal() {
471 return Ok(true);
472 }
473 print!("{question} {} ", s.tr.text("cli-yes-no", None));
474 std::io::stdout().flush().map_err(|e| e.to_string())?;
475 let mut line = String::new();
476 std::io::stdin().lock().read_line(&mut line).map_err(|e| e.to_string())?;
477 let answer = line.trim().to_lowercase();
478 Ok(!(answer.starts_with('n')))
479}
480
481fn say_report(s: &Session, account: &Account, report: &Report) {
483 let mut args = s.tr.counted(report.new.len());
484 args.set("account", account.id.clone());
485 args.set("days", account.sync_days.unwrap_or(DEFAULT_SYNC_DAYS));
486 println!("{}", s.tr.text(if report.first { "sync-first" } else { "sync-new" }, Some(&args)));
487}
488
489fn learn_provider(s: &Session, account: &Account, say_failure: bool) {
492 match sioul_sync::learn_provider(&s.config_path, account) {
493 Ok(Learned::Id(id)) => println!("{}", s.say("sync-learned", &[("account", account.id.clone()), ("id", crate::one_line(&id))])),
494 Ok(Learned::Nothing) if say_failure => println!("{}", s.say("sync-not-learned", &[("account", account.id.clone())])),
495 Ok(_) => {}
496 Err(e) => eprintln!("{}", crate::plain_lines(&e.to_string())),
497 }
498}
499
500fn syncing<'a>(config: &'a Config, only: Option<&str>) -> Vec<&'a Account> {
502 config.accounts.iter().filter(|a| a.syncs() && only.is_none_or(|id| a.id == id)).collect()
503}
504
505pub(crate) fn sync_command(s: &Session, only: Option<&str>) -> Result<(), String> {
506 let accounts = syncing(&s.config, only);
507 if accounts.is_empty() {
508 return Err(match only {
509 Some(id) => s.say("account-unknown", &[("id", id.to_string())]),
510 None => s.tr.text("sync-nothing", None),
511 });
512 }
513 let mut arrived = Vec::new();
514 for account in accounts {
515 match secret::password(account).and_then(|p| sioul_sync::sync(account, &p)) {
516 Ok(report) => {
517 say_report(s, account, &report);
518 learn_provider(s, account, report.first);
519 if !report.first {
520 arrived.extend(report.new);
521 }
522 }
523 Err(e) => eprintln!("{}", crate::plain_lines(&e.sentence(&s.tr, &account.id))),
524 }
525 }
526 let config = Config::load(&s.config_path).unwrap_or_default();
528 println!();
529 print_right_now(s, &triage_files(&config, &arrived));
530 Ok(())
531}
532
533fn triage_files(config: &Config, files: &[PathBuf]) -> Vec<Triaged> {
535 let store = load_store(config);
536 let known = KnownSenders::load(&config.known_senders_path());
537 let senders = porch::Senders::load(config);
538 porch::judge(files, &config.mail_sources(), store.as_ref(), &known, &senders, Zoned::now().timestamp().as_second())
539}
540
541fn filter_on_server(s: &Session, config: &Config, account: &Account, new: &[PathBuf]) {
547 let Ok(password) = secret::password(account) else { return };
548 let done = sioul_sync::filters::after_fetch(config, account, &password, new, false);
549 let time = Zoned::now().strftime("%H:%M").to_string();
550 if !done.acted.is_empty() {
551 let mut args = s.tr.counted(done.acted.len());
552 args.set("account", account.id.clone());
553 let line = s.tr.text("filter-cli-acted", Some(&args));
554 println!("{}", s.say("watch-line", &[("time", time.clone()), ("line", line)]));
555 }
556 if let Some(why) = done.said(&s.tr, account.address.as_deref().unwrap_or(&account.id)) {
557 println!("{}", s.say("watch-line", &[("time", time), ("line", why)]));
558 }
559}
560
561pub(crate) fn watch_command(s: &Session) -> Result<(), String> {
562 let accounts: Vec<Account> = syncing(&s.config, None).into_iter().cloned().collect();
563 if accounts.is_empty() {
564 return Err(s.tr.text("sync-nothing", None));
565 }
566 let names = accounts.iter().map(|a| a.id.clone()).collect::<Vec<_>>().join(", ");
567 println!("{}", s.say("watch-started", &[("accounts", names)]));
568 crate::spam::by_itself::every_minute(s);
570 let control = Arc::new(Control::default());
572 let (sender, receiver) = mpsc::channel::<(String, Result<Report, SyncError>)>();
573 for account in accounts {
574 let password = match secret::password(&account) {
575 Ok(password) => password,
576 Err(e) => {
577 eprintln!("{}", crate::plain_lines(&e.sentence(&s.tr, &account.id)));
578 continue;
579 }
580 };
581 let (sender, control) = (sender.clone(), Arc::clone(&control));
582 std::thread::spawn(move || {
583 let id = account.id.clone();
584 sioul_sync::watch(&account, &password, &control, |result| {
585 let _ = sender.send((id.clone(), result));
586 });
587 });
588 }
589 drop(sender);
590 for (id, result) in receiver {
591 let time = Zoned::now().strftime("%H:%M").to_string();
592 match result {
593 Ok(report) if report.new.is_empty() => {
594 let config = Config::load(&s.config_path).unwrap_or_default();
596 if let Some(account) = config.account(&id).filter(|a| !report.first && sioul_sync::filters::due(a)) {
597 filter_on_server(s, &config, account, &[]);
598 }
599 }
600 Ok(report) => {
601 let config = Config::load(&s.config_path).unwrap_or_default();
602 if let Some(account) = config.account(&id) {
603 learn_provider(s, account, report.first);
604 }
605 let mut args = s.tr.counted(report.new.len());
606 args.set("account", id.clone());
607 let line = s.tr.text("sync-new", Some(&args));
608 println!("{}", s.say("watch-line", &[("time", time), ("line", line)]));
609 if report.first {
610 continue;
611 }
612 let config = Config::load(&s.config_path).unwrap_or_default();
613 if let Some(account) = config.account(&id) {
614 filter_on_server(s, &config, account, &report.new);
615 }
616 let arrived = triage_files(&config, &report.new);
617 print_right_now(s, &arrived);
618 use sioul_core::attention::{self, Attention, Kind, Level, Row, Slots};
622 let now = Zoned::now();
623 let stamp = now.timestamp().as_second();
624 let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
625 let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &sioul_core::quiet::Blocks::read_now(&now), &now);
626 let moment = attention::Now::of(&mode).layers(Slots::load(&Slots::default_path()).at(&now), attention::dnd_from_files(&config, stamp));
627 if Attention::of(&config).level(Row::Own(Kind::Codes), &moment) != Level::Now {
628 continue;
629 }
630 for code in view::codes(&arrived, &s.tr) {
631 if let Err(e) = notify::code(&code.title, &code.body(), None) {
632 eprintln!("{}", crate::plain_lines(&e));
633 }
634 }
635 }
636 Err(e) => eprintln!("{}", crate::plain_lines(&s.say("watch-line", &[("time", time), ("line", e.sentence(&s.tr, &id))]))),
637 }
638 }
639 Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use sioul_core::i18n::Translator;
646
647 #[test]
649 fn a_google_account_has_no_password() {
650 let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
651 let account = |auth: Option<&str>| Account { auth: auth.map(str::to_string), ..Account::imap("me", "me@example.org", "imap.example.org", 993, Security::Tls, None) };
652 assert_eq!(secret_said(&s, &account(Some("google")), Ok("google:2026-10-09".into())), "signed in with Google (the access in the keyring)");
654 assert_eq!(secret_said(&s, &account(None), Ok("hunter2".into())), "password in the keyring");
655 assert_eq!(secret_said(&s, &account(None), Err(SyncError::NoPassword)), "no password yet: `sioul account password me`");
656 }
657
658 #[test]
661 fn a_microsoft_account_has_no_password() {
662 let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
663 let mut account = Account::imap("me", "me@outlook.com", "outlook.office365.com", 993, Security::Tls, None);
664 account.auth = Some("microsoft".into());
665 assert_eq!(secret_said(&s, &account, Ok("microsoft:1760000000".into())), "signed in with Microsoft (the access in the keyring)");
666 let config: Config = toml::from_str("[[account]]\nid = \"me\"\nkind = \"imap\"\naddress = \"me@outlook.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\n").unwrap();
667 let s = Session { config, config_path: PathBuf::new(), tr: Translator::new("en") };
668 let refused = password(&s, "me").unwrap_err();
669 assert!(refused.contains("sioul account sign-in me"), "{refused}");
670 }
671
672 #[test]
676 fn another_logins_mailbox_is_no_by_default() {
677 use std::io::IsTerminal;
678 assert!(said_yes("y\n") && said_yes("Yes") && said_yes("oui\n") && said_yes(" O "));
679 assert!(!said_yes("\n") && !said_yes("n") && !said_yes("non") && !said_yes("") && !said_yes("maybe"));
680 let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("fr") };
681 assert_eq!(s.tr.text("cli-no-yes", None), "[o/N]");
682 if !std::io::stdin().is_terminal() {
683 assert_eq!(ask_no(&s, "?"), Ok(false));
684 }
685 }
686}