Skip to main content

sioul/
main.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! `sioul`: the Porch, projects, admin windows, budgets and mail accounts, from the terminal.
5//!
6//! The terminal is the first interface because agents and scripts use it too;
7//! the Qt interface comes on top of the same core (docs/roadmap.md). Every
8//! sentence goes through the translator: the language is the configuration's,
9//! else the session's (docs/i18n.md).
10//!
11//! This crate is `crates/sioul-cli` in the repository; the program it builds,
12//! and so this reference, are named `sioul`. It decides nothing itself: it
13//! reads and judges with [sioul-core](../sioul_core/index.html), reaches
14//! servers with [sioul-sync](../sioul_sync/index.html), and prints. `sioul
15//! --help`, or `sioul <command> --help`, says what each command takes.
16//!
17//! # Where to start reading
18//!
19//! - [`Command`]: one variant per command (`sioul porch`, `sioul tasks …`).
20//!   Its comments are also the help the program prints, through `clap`.
21//!   [`Cli`] holds the options every command takes.
22//! - [`main`]: reads the command line, makes the [`Session`] (the
23//!   configuration, where it lives, the language), then runs the command.
24//! - The commands that have commands of their own are in the modules below;
25//!   the others (`sioul porch`, `sioul budgets`, `sioul block`…) are
26//!   functions of this file, named after them ([`porch_command`]).
27//!
28//! Names such as docs/mcp.md are the design notes in the repository's `docs/`
29//! folder. The website shows them too, under the same name:
30//! <https://sioul.aurelienpierre.com/dev/mcp.html>.
31//!
32//! # Modules
33//!
34//! - [`accounts`]: mail accounts and sync: `sioul account …`, `sioul sync`, `sioul watch`.
35//! - [`mail`]: the mail client: `sioul mail folders | list | act | send`.
36//! - [`pgp`]: OpenPGP keys: `sioul pgp list | make | import | export | lookup`.
37//! - [`dav`]: contacts and calendars: `sioul dav …`, `sioul contacts`, `sioul agenda`, `sioul event`.
38//! - [`tasks`]: tasks, notes, links and focus: `sioul tasks …`, `sioul focus …`, `sioul notes`, `sioul links`.
39//! - [`remind`]: reminders before dates: `sioul remind`, and `--watch` with the window closed.
40//! - [`mcp`]: `sioul mcp`, what Sioul keeps served to AI agents over the Model Context Protocol.
41//! - [`spam`]: `sioul spam`, the spam filter learned on this computer (docs/spam-filter.md); computers only.
42
43mod accounts;
44mod dav;
45mod mail;
46mod mcp;
47mod pgp;
48mod remind;
49// The spam filter learned on this computer (sioul-learn): desktops only.
50#[cfg(not(target_os = "android"))]
51mod spam;
52mod tasks;
53
54use clap::{Parser, Subcommand};
55use jiff::{Timestamp, Zoned, tz::TimeZone};
56use sioul_core::budget::{self, Ledger};
57use sioul_core::projects::ProjectStore;
58use sioul_core::config::{self, Config, Source};
59use sioul_core::i18n::{self, Translator};
60use sioul_core::porch::{self, Context, KnownSenders, Lane, SenderList, Triaged};
61use sioul_core::state::{MoneyState, PorchState};
62use sioul_core::{maildir, trust, view, window};
63use std::path::{Path, PathBuf};
64use std::process::ExitCode;
65
66#[derive(Parser)]
67// `sioul --version`: the version and the commit it was built from (docs/building.md, "Which build").
68#[command(name = "sioul", version = sioul_core::build::DESCRIBED, about = "Calm admin: the Porch, projects and admin windows.")]
69struct Cli {
70    /// Configuration file (default: ~/.config/sioul/config.toml).
71    #[arg(long, global = true)]
72    config: Option<PathBuf>,
73    /// Language of the sentences ("fr", "en"); default: the configuration's, else the session's.
74    #[arg(long, global = true)]
75    language: Option<String>,
76    #[command(subcommand)]
77    command: Command,
78}
79
80#[derive(Subcommand)]
81enum Command {
82    /// What came, checked and sorted. Outside admin windows, only one-time codes show.
83    Porch {
84        /// Read this Maildir or folder of .eml files instead of the configured accounts.
85        #[arg(long)]
86        maildir: Vec<PathBuf>,
87        /// Show everything now, even outside an admin window.
88        #[arg(long)]
89        open: bool,
90        /// Also show what was there when the Porch was last closed.
91        #[arg(long)]
92        all: bool,
93    },
94    /// Closes the Porch on what it shows: what comes next waits for the next window.
95    Done,
96    /// One message: its card, its lane and the reasons (a developer's view).
97    Card { file: PathBuf },
98    /// The projects of the notes folder, and the files they name.
99    #[command(alias = "cases")]
100    Projects {
101        /// The notes folder (default: case_store in the configuration).
102        #[arg(long)]
103        store: Option<PathBuf>,
104    },
105    /// When the next admin window opens.
106    Window,
107    /// Budgets and reserves at a glance, and the lines mail proposes.
108    Budgets {
109        /// The budget file (default: sioul-budgets.toml at the root of the notes folder).
110        #[arg(long)]
111        file: Option<PathBuf>,
112        /// Read mail about money in this Maildir or folder of .eml files, instead of the accounts'.
113        #[arg(long)]
114        maildir: Vec<PathBuf>,
115    },
116    /// Mail accounts: add, list, test, change a password, remove.
117    #[command(subcommand)]
118    Account(accounts::AccountCommand),
119    /// The mail client: folders, messages, what you do to them, writing and sending.
120    #[command(subcommand)]
121    Mail(mail::MailCommand),
122    /// OpenPGP keys: yours and others'.
123    #[command(subcommand)]
124    Pgp(pgp::PgpCommand),
125    /// Contacts and calendars on a CardDAV and CalDAV server: add an account, sync.
126    #[command(subcommand)]
127    Dav(dav::DavCommand),
128    /// Your contacts, or those matching a name, an address, a number.
129    Contacts { query: Option<String> },
130    /// Adds a contact to the first address book, and sends it.
131    Contact {
132        #[arg(long)]
133        name: String,
134        #[arg(long)]
135        email: Vec<String>,
136        #[arg(long)]
137        phone: Vec<String>,
138    },
139    /// What comes: today, then the next days.
140    Agenda {
141        #[arg(long, default_value_t = 15, value_parser = clap::value_parser!(i64).range(1..=366))]
142        days: i64,
143    },
144    /// Adds an event to the first calendar, and sends it. Times: "2026-10-05T09:00", or a date with --all-day.
145    Event {
146        #[arg(long)]
147        title: String,
148        #[arg(long)]
149        start: String,
150        #[arg(long)]
151        end: Option<String>,
152        #[arg(long)]
153        all_day: bool,
154        #[arg(long)]
155        location: Option<String>,
156        /// daily, weekly, monthly or yearly.
157        #[arg(long)]
158        repeat: Option<String>,
159    },
160    /// Blocks a sender (an address, or @domain for everyone there): their mail is set aside
161    /// for good, never shown, never counted. Without an argument, lists who is blocked.
162    Block { entry: Option<String> },
163    /// Unblocks a sender.
164    Unblock { entry: String },
165    /// Checks SPF, DKIM, DMARC, ARC and reverse DNS on the mail Sioul has not checked yet
166    /// (mail fetched from now on is checked as it arrives).
167    Verify {
168        /// Check all the mail again, replacing earlier results.
169        #[arg(long)]
170        again: bool,
171    },
172    /// Tasks: the next step, the list, the board, the timeline; adding, importing.
173    #[command(subcommand)]
174    Tasks(tasks::TasksCommand),
175    /// A focus session on one task: start, status, stop.
176    #[command(subcommand)]
177    Focus(tasks::FocusCommand),
178    /// What a thing is tied to, both ways: `sioul:task/<UID>`, `mid:<Message-ID>`, `sioul:note/<path>`…
179    Links { uri: String },
180    /// Notes of the notes folder: those matching, or one with --path.
181    Notes {
182        query: Vec<String>,
183        #[arg(long)]
184        path: Option<String>,
185    },
186    /// Fetches new mail, changing nothing on the server: from every account, or one.
187    Sync {
188        #[arg(long)]
189        account: Option<String>,
190    },
191    /// Stays open and fetches mail as it arrives; verified codes come as notifications.
192    Watch,
193    /// Reminders before dates: what comes in the next two weeks, and what was told.
194    Remind {
195        /// Stays open, with Sioul's window closed too: each minute, what is due becomes one
196        /// quiet notification, never repeated (the window marks the same ones).
197        #[arg(long)]
198        watch: bool,
199    },
200    /// What the shield of a public address makes of its mail: tone, topic, and the words
201    /// that weighed. Hostile mail shows neither its sender's name nor its subject.
202    Shield {
203        /// The account, by its id; every shielded one by default.
204        #[arg(long)]
205        account: Option<String>,
206        /// First lets the AI read what it has not read yet, where the address allows it.
207        #[arg(long)]
208        read: bool,
209    },
210    /// Serves Sioul to AI agents (Claude Code, Claude Desktop…) over MCP, on standard input
211    /// and output: they read and write this computer's files only, and never send (docs/mcp.md).
212    Mcp,
213    /// Sioul's own spam filter, learned on this computer from your mail: the training
214    /// corpus, training, its numbers, and why it judges a message as it does.
215    #[cfg(not(target_os = "android"))]
216    #[command(subcommand)]
217    Spam(spam::SpamCommand),
218}
219
220/// What every command needs: the configuration, where it lives, and the language.
221pub(crate) struct Session {
222    pub config: Config,
223    pub config_path: PathBuf,
224    pub tr: Translator,
225}
226
227impl Session {
228    /// A sentence with its arguments.
229    pub fn say(&self, id: &str, pairs: &[(&str, String)]) -> String {
230        let mut args = i18n::args();
231        for (key, value) in pairs {
232            // A whole number goes as a number, so that "one" and "other" choose by
233            // it ("1 message", "2 messages"); one written otherwise ("007") stays as written.
234            match value.parse::<i32>() {
235                Ok(n) if n.to_string() == *value => args.set(key.to_string(), n),
236                _ => args.set(key.to_string(), value.clone()),
237            }
238        }
239        self.tr.text(id, Some(&args))
240    }
241}
242
243/// Words from elsewhere (mail, invitations, contacts, file names) on one
244/// line, as they are shown: a line break or a tab becomes a space, and the
245/// other control characters go (a terminal's escape sequences, the marks that
246/// turn text right to left), so that they can neither break a line of what is
247/// shown, nor pass for it, nor drive the terminal.
248pub(crate) fn one_line(text: &str) -> String {
249    let mut out = String::with_capacity(text.len());
250    for c in text.chars() {
251        match c {
252            '\t' | '\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => {
253                if !out.ends_with(' ') {
254                    out.push(' ');
255                }
256            }
257            c if hidden(c) => {}
258            c => out.push(c),
259        }
260    }
261    out.trim().to_string()
262}
263
264/// Words from elsewhere that keep their lines (a task's notes, what a server
265/// answered): line breaks and tabs stay, the other control characters go, as
266/// in `one_line`.
267pub(crate) fn plain_lines(text: &str) -> String {
268    text.replace("\r\n", "\n")
269        .chars()
270        .map(|c| if matches!(c, '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}') { '\n' } else { c })
271        .filter(|&c| c == '\n' || c == '\t' || !hidden(c))
272        .collect()
273}
274
275/// A control character (escape sequences start with one), or a mark that
276/// turns text right to left: never printed.
277fn hidden(c: char) -> bool {
278    c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
279}
280
281/// The session's language: its variables (LC_ALL, LC_MESSAGES, LANG) when it
282/// has them, as in a Linux terminal; else the system's own setting, as
283/// Windows' and macOS' terminals may set none (as sioul-app's backend.rs).
284fn session_language() -> String {
285    let said = ["LC_ALL", "LC_MESSAGES", "LANG"].iter().any(|v| std::env::var(v).is_ok_and(|l| !l.trim().is_empty()));
286    if said {
287        return i18n::system_language();
288    }
289    system_locale().unwrap_or_else(i18n::system_language)
290}
291
292/// Windows' language for the user ("fr-FR").
293#[cfg(windows)]
294fn system_locale() -> Option<String> {
295    #[link(name = "kernel32")]
296    unsafe extern "system" {
297        fn GetUserDefaultLocaleName(name: *mut u16, size: i32) -> i32;
298    }
299    // LOCALE_NAME_MAX_LENGTH characters, the final zero included.
300    let mut name = [0u16; 85];
301    // SAFETY: a buffer of `name.len()` UTF-16 characters, as the function asks; it writes no further.
302    let written = unsafe { GetUserDefaultLocaleName(name.as_mut_ptr(), name.len() as i32) };
303    let length = usize::try_from(written).ok().filter(|n| *n > 1 && *n <= name.len())?;
304    Some(String::from_utf16_lossy(&name[..length - 1]))
305}
306
307/// The first of the languages chosen in macOS's settings ("fr-FR").
308#[cfg(target_os = "macos")]
309fn system_locale() -> Option<String> {
310    // `defaults` prints them as a list: ( "fr-FR", "en-FR" ).
311    let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
312    String::from_utf8_lossy(&out.stdout).split('"').nth(1).map(str::to_string).filter(|l| !l.is_empty())
313}
314
315/// Elsewhere the session's variables say it all.
316#[cfg(not(any(windows, target_os = "macos")))]
317fn system_locale() -> Option<String> {
318    None
319}
320
321fn main() -> ExitCode {
322    // Before anything else: no crate logs above "info", a security key's PIN never.
323    sioul_sync::securitykey::cap_logging();
324    let cli = Cli::parse();
325    // Mail, keys, drafts and caches are kept in folders that are yours alone.
326    config::make_private_dirs();
327    let config_path = cli.config.clone().unwrap_or_else(config::default_path);
328    // No file yet: Sioul's defaults. A file that cannot be read is said, not taken for none.
329    let config = Config::load(&config_path).unwrap_or_else(|e| {
330        if config_path.exists() {
331            eprintln!("{e}");
332        }
333        Config::default()
334    });
335    let language = cli.language.clone().or_else(|| config.language.clone()).unwrap_or_else(session_language);
336    // The words this program looks for (`words::current`: a server's folder names).
337    sioul_core::words::set_current(sioul_core::words::Words::of(&config));
338    let session = Session { tr: Translator::new(&language), config, config_path };
339    let result = match cli.command {
340        Command::Porch { maildir, open, all } => porch_command(&session, &maildir, open, all),
341        Command::Done => done_command(&session),
342        Command::Card { file } => card_command(&session, &file),
343        Command::Projects { store } => projects_command(&session, store),
344        Command::Window => window_command(&session),
345        Command::Budgets { file, maildir } => budgets_command(&session, file, &maildir),
346        Command::Account(command) => accounts::run(&session, command),
347        Command::Mail(command) => mail::run(&session, command),
348        Command::Dav(command) => dav::run(&session, command),
349        Command::Pgp(command) => pgp::run(&session, command),
350        Command::Contacts { query } => dav::list_contacts(&session, query.as_deref().unwrap_or("")),
351        Command::Contact { name, email, phone } => dav::new_contact(&session, &name, &email, &phone),
352        Command::Agenda { days } => dav::list_agenda(&session, days),
353        Command::Event { title, start, end, all_day, location, repeat } => {
354            let edit = sioul_core::agenda::EventEdit {
355                end: end.unwrap_or_else(|| start.clone()),
356                title,
357                start,
358                all_day,
359                location: location.unwrap_or_default(),
360                repeat: repeat.unwrap_or_default(),
361                notes: String::new(),
362                ..sioul_core::agenda::EventEdit::default()
363            };
364            dav::new_event(&session, &edit)
365        }
366        Command::Block { entry } => block_command(&session, entry.as_deref()),
367        Command::Unblock { entry } => unblock_command(&session, &entry),
368        Command::Verify { again } => verify_command(&session, again),
369        Command::Tasks(command) => tasks::run(&session, command),
370        Command::Focus(command) => tasks::focus(&session, command),
371        Command::Links { uri } => tasks::links_command(&session, &uri),
372        Command::Notes { query, path } => tasks::notes_command(&session, &query.join(" "), path.as_deref()),
373        Command::Sync { account } => accounts::sync_command(&session, account.as_deref()),
374        Command::Watch => accounts::watch_command(&session),
375        Command::Remind { watch: false } => remind::list(&session),
376        Command::Remind { watch: true } => remind::watch(&session),
377        Command::Shield { account, read } => shield_command(&session, account.as_deref(), read),
378        Command::Mcp => mcp::run(&session),
379        #[cfg(not(target_os = "android"))]
380        Command::Spam(command) => spam::run(&session, command),
381    };
382    result.map_or_else(
383        |message| {
384            // What went wrong may quote a server's answer, or a title from a mail.
385            eprintln!("{}", plain_lines(&message));
386            ExitCode::FAILURE
387        },
388        |()| ExitCode::SUCCESS,
389    )
390}
391
392/// The configured accounts' mail, or the folders given instead.
393fn sources(config: &Config, maildirs: &[PathBuf]) -> Vec<Source> {
394    if maildirs.is_empty() {
395        return config.mail_sources();
396    }
397    let ids = config.all_trusted_ids();
398    maildirs.iter().map(|m| Source { account: None, address: None, folder: m.clone(), trusted_ids: ids.clone(), priority: Default::default(), shielded: false, words: sioul_core::words::Words::of(config), spam: Some(sioul_core::spam::Filter::of(config)) }).collect()
399}
400
401pub(crate) fn load_store(config: &Config) -> Option<ProjectStore> {
402    let root = config.notes_root_path()?;
403    // With the mail tied to projects, so their conversations follow.
404    let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
405    ProjectStore::load(&root).map_err(|e| eprintln!("{}", plain_lines(&e))).ok().map(|s| s.with_ties(&ties))
406}
407
408fn porch_command(s: &Session, maildirs: &[PathBuf], open: bool, all: bool) -> Result<(), String> {
409    let sources = sources(&s.config, maildirs);
410    if sources.is_empty() {
411        return Err(s.tr.text("error-no-mail", None));
412    }
413    let store = load_store(&s.config);
414    let known = KnownSenders::load(&s.config.known_senders_path());
415    let senders = porch::Senders::load(&s.config);
416    let state = if all { PorchState::default() } else { PorchState::load(&PorchState::default_path()) };
417    let now = Zoned::now();
418    let triaged = porch::gather(&sources, store.as_ref(), &known, &senders, &state, now.timestamp().as_second());
419    print_right_now(s, &triaged);
420    let is_open = open || s.config.windows.is_empty() || window::current(&s.config.windows, &now).is_some();
421    if is_open {
422        print_open_porch(s, &triaged, store.as_ref());
423    } else {
424        print_closed_porch(s, &now);
425    }
426    Ok(())
427}
428
429fn shield_command(s: &Session, account: Option<&str>, read: bool) -> Result<(), String> {
430    use sioul_core::shield::{self, AiCache, Tone};
431    let accounts: Vec<_> = s.config.accounts.iter().filter(|a| a.shield && account.is_none_or(|id| a.id == id)).collect();
432    if accounts.is_empty() {
433        return Err(s.tr.text("shield-none", None));
434    }
435    if read {
436        let done = sioul_sync::shield_ai::read_new(&s.config).map_err(|e| e.sentence(&s.tr, "Anthropic"))?;
437        println!("{}", s.say("shield-read", &[("n", done.read.to_string()), ("left", done.left.to_string())]));
438    }
439    for account in accounts {
440        println!("{}", account.address.as_deref().unwrap_or(&account.id));
441        let cache = AiCache::load(&account.id);
442        // Newest first, as the Porch reads a public address.
443        for card in maildir::read_messages(&account.maildir_path()).iter().rev() {
444            let assessment = shield::reading(card, Some(&cache.messages));
445            let tone = s.tr.text(&format!("tone-{}", match assessment.tone { Tone::Calm => "calm", Tone::Rude => "rude", Tone::Hostile => "hostile" }), None);
446            let (sender, subject) = if assessment.tone == Tone::Hostile {
447                let domain = card.from_address.as_deref().and_then(|a| a.rsplit_once('@')).map(|(_, d)| d.to_string()).unwrap_or_default();
448                (s.say("hostile-someone-at", &[("domain", domain)]), s.tr.text("hostile-subject", None))
449            } else {
450                (card.sender().to_string(), card.subject.clone())
451            };
452            println!("  {tone:<8} {:<16} {} · {}", s.tr.topic(assessment.topic), one_line(&sender), one_line(&subject));
453            if !assessment.summary.is_empty() {
454                println!("           {}", one_line(&assessment.summary));
455            } else if !assessment.words.is_empty() && assessment.tone != Tone::Hostile {
456                println!("           {}", one_line(&s.say("shield-words", &[("words", assessment.words.join(", "))])));
457            }
458        }
459    }
460    Ok(())
461}
462
463fn done_command(s: &Session) -> Result<(), String> {
464    let path = PorchState::default_path();
465    let mut state = PorchState::load(&path);
466    let senders = porch::Senders::load(&s.config);
467    let shown = porch::gather(&s.config.mail_sources(), None, &KnownSenders::default(), &senders, &state, Timestamp::now().as_second());
468    let newest = PorchState::newest_shown(&shown);
469    if newest.is_empty() {
470        println!("{}", s.tr.text("done-nothing", None));
471        return Ok(());
472    }
473    state.close(&newest);
474    state.save(&path)?;
475    println!("{}", s.tr.text("done-closed", None));
476    Ok(())
477}
478
479/// The one exception to the windows: verified codes, shown whatever the time.
480pub(crate) fn print_right_now(s: &Session, triaged: &[Triaged]) {
481    let codes: Vec<&Triaged> = triaged.iter().filter(|t| t.lane == Lane::RightNow).collect();
482    if codes.is_empty() {
483        return;
484    }
485    println!("{}", s.tr.text("right-now-title", None));
486    for t in codes {
487        let Some(code) = &t.code else { continue };
488        println!("  {}", one_line(&s.tr.right_now(code.kind, t.card.sender(), t.trust, code.code.as_deref(), code.expires_minutes)));
489    }
490    println!();
491}
492
493fn print_closed_porch(s: &Session, now: &Zoned) {
494    let next = window::next_opening(&s.config.windows, now);
495    let mut args = sioul_core::i18n::args();
496    args.set("when", next.map_or_else(String::new, |z| s.tr.when(&z)));
497    println!("{}", s.tr.text("porch-closed", Some(&args)));
498    println!("{}", s.tr.text("porch-open-hint", None));
499}
500
501fn print_open_porch(s: &Session, triaged: &[Triaged], store: Option<&ProjectStore>) {
502    println!("{}\n", plain_lines(&s.tr.summary(&porch::summarise(triaged), store)));
503    for project in store.map_or(&[][..], |st| &st.projects[..]) {
504        print_lane(s, &project.title, triaged, &Lane::Project(project.id.clone()), false, store);
505    }
506    print_lane(s, &s.tr.text("lane-people", None), triaged, &Lane::People, false, store);
507    print_lane(s, &s.tr.text("lane-screener", None), triaged, &Lane::Screener, true, store);
508    print_lane(s, &s.tr.text("lane-filed", None), triaged, &Lane::Filed, false, store);
509    print_lane(s, &s.tr.text("lane-low", None), triaged, &Lane::Low, false, store);
510    print_lane(s, &s.tr.text("lane-review", None), triaged, &Lane::Review, true, store);
511    print_lane(s, &s.tr.text("lane-set-aside", None), triaged, &Lane::SetAside, true, store);
512}
513
514fn print_lane(s: &Session, title: &str, triaged: &[Triaged], lane: &Lane, with_reason: bool, store: Option<&ProjectStore>) {
515    let items: Vec<&Triaged> = triaged.iter().filter(|t| &t.lane == lane).collect();
516    if items.is_empty() {
517        return;
518    }
519    println!("{}", one_line(title));
520    for t in items {
521        // The last reason is the one that chose the lane, or the warning that follows it.
522        let reason = match (with_reason, t.reasons.last()) {
523            (true, Some(r)) => format!(" ({})", one_line(&s.tr.reason(r, store))),
524            _ => String::new(),
525        };
526        println!("  · {} ({}) · {} · {}{reason}", one_line(t.card.sender()), s.tr.trust(t.trust), one_line(&t.card.subject), date(s, t.card.date));
527    }
528    println!();
529}
530
531fn card_command(s: &Session, file: &Path) -> Result<(), String> {
532    let card = maildir::read_one(file).ok_or_else(|| format!("{}: ?", file.display()))?;
533    let store = load_store(&s.config);
534    let known = KnownSenders::load(&s.config.known_senders_path());
535    let senders = porch::Senders::load(&s.config);
536    let ids = s.config.all_trusted_ids();
537    let own = sioul_core::lookalike::own_domains(&sioul_core::words::Words::of(&s.config).brands.shared, s.config.accounts.iter().filter_map(|a| a.address.as_deref()));
538    let auth = trust::read_auth_results(&card.headers, &ids);
539    let spam = trust::read_spam_verdict(&card.headers);
540    let route: Vec<String> = trust::route_ips(&card.headers).iter().map(ToString::to_string).collect();
541    let own_addresses = porch::own_addresses(&s.config.mail_sources());
542    // Sioul's own spam filter, as the Porch asks it.
543    let filter = sioul_core::spam::Filter::of(&s.config);
544    let words = sioul_core::words::Words::of(&s.config);
545    let ctx = Context { projects: store.as_ref(), known: &known, senders: &senders, trusted_ids: &ids, now: Some(Timestamp::now().as_second()), priority: Default::default(), own_domains: &own, shielded: false, assessments: None, words: Some(&words), own_addresses: &own_addresses, spam: Some(&filter) };
546    let t = porch::triage(card, &ctx);
547    println!("From      {} <{}>", one_line(t.card.sender()), one_line(t.card.from_address.as_deref().unwrap_or("?")));
548    println!("Subject   {}", one_line(&t.card.subject));
549    println!("Date      {}", date(s, t.card.date));
550    println!("Trust     {}", s.tr.trust(t.trust));
551    println!("Lane      {:?}", t.lane);
552    for reason in &t.reasons {
553        println!("          · {}", one_line(&s.tr.reason(reason, store.as_ref())));
554    }
555    println!("Provider  {auth:?}");
556    println!("Spam      {spam:?}");
557    println!("Route     {}", route.join(" ← "));
558    if let Some(code) = &t.code {
559        println!("Code      {code:?}");
560    }
561    if let Some(reading) = sioul_core::reading::read(file) {
562        for a in &reading.attachments {
563            println!("Attached  {} ({}, {} bytes)", one_line(&a.name), one_line(&a.mime), a.size);
564        }
565        for part in &reading.parts {
566            let line = format!("{part:?}").replace('\n', " ⏎ ");
567            println!("Part      {}", line.chars().take(110).collect::<String>());
568        }
569    }
570    Ok(())
571}
572
573fn projects_command(s: &Session, store: Option<PathBuf>) -> Result<(), String> {
574    let root = store.or_else(|| s.config.notes_root_path()).ok_or_else(|| s.tr.text("error-no-store", None))?;
575    let store = ProjectStore::load(&root)?;
576    let missing = store.missing_files();
577    for project in &store.projects {
578        let status = project.status.as_deref().map_or(String::new(), |st| format!(" [{}]", one_line(st)));
579        println!("{} · {}{status}", one_line(&project.id), one_line(&project.title));
580        for file in &project.files {
581            let gone = missing.iter().any(|(id, f)| id == &project.id && f == file);
582            println!("    {}{}", one_line(file), if gone { "  (?)" } else { "" });
583        }
584    }
585    Ok(())
586}
587
588fn window_command(s: &Session) -> Result<(), String> {
589    if s.config.windows.is_empty() {
590        return Err(s.tr.text("error-no-windows", None));
591    }
592    let now = Zoned::now();
593    let mut args = sioul_core::i18n::args();
594    let id = match window::current(&s.config.windows, &now) {
595        Some((_, closing)) => {
596            args.set("time", closing.strftime("%H:%M").to_string());
597            "window-open-until"
598        }
599        None => {
600            let next = window::next_opening(&s.config.windows, &now).ok_or_else(|| s.tr.text("window-none", None))?;
601            args.set("when", s.tr.when(&next));
602            "window-next"
603        }
604    };
605    println!("{}", s.tr.text(id, Some(&args)));
606    Ok(())
607}
608
609fn budgets_command(s: &Session, file: Option<PathBuf>, maildirs: &[PathBuf]) -> Result<(), String> {
610    let path = file
611        .or_else(|| s.config.notes_root_path().map(|root| root.join(budget::LEDGER)))
612        .ok_or_else(|| s.tr.text("error-no-ledger", None))?;
613    let ledger = Ledger::load_file(&path)?;
614    // With the bank accounts' movements, read from the exports beside the file.
615    let ledger = match path.parent().map(sioul_core::bank::Bank::load) {
616        Some(Ok(bank)) => ledger.with_bank(&bank, &sioul_core::words::Words::of(&s.config)),
617        _ => ledger,
618    };
619    let today = Zoned::now().date();
620    let mut args = sioul_core::i18n::args();
621    args.set("month", s.tr.month_year(today));
622    println!("{}", s.tr.text("budgets-title", Some(&args)));
623    for b in &ledger.budgets {
624        let status = ledger.status(b, today);
625        let reserve = status.reserve_transfer.as_ref().map(|(id, _)| ledger.reserve_title(id));
626        print_indented(&s.tr.budget_lines(&b.title, b.period, &status, reserve));
627    }
628    if !ledger.reserves.is_empty() {
629        println!("\n{}", s.tr.text("reserves-title", None));
630    }
631    for r in &ledger.reserves {
632        print_indented(&s.tr.reserve_lines(&r.title, &ledger.reserve_status(r, today), today));
633    }
634    print_mail_lines(s, &ledger, maildirs);
635    Ok(())
636}
637
638/// The first line of a block at two spaces, its follow-ups at four.
639fn print_indented(lines: &[String]) {
640    for (i, line) in lines.iter().enumerate() {
641        println!("{}{}", if i == 0 { "  " } else { "    " }, one_line(line));
642    }
643}
644
645/// Mail about money, from every account (or the folders given), and what becomes of it.
646fn print_mail_lines(s: &Session, ledger: &Ledger, maildirs: &[PathBuf]) {
647    let store = load_store(&s.config);
648    let known = KnownSenders::load(&s.config.known_senders_path());
649    let senders = porch::Senders::load(&s.config);
650    let now = Zoned::now();
651    // Every message, the ones the Porch was closed on included: payments are not news.
652    let items = porch::gather(&sources(&s.config, maildirs), store.as_ref(), &known, &senders, &PorchState::default(), now.timestamp().as_second());
653    let ignored = MoneyState::load(&MoneyState::default_path()).ignored;
654    let lines = budget::mail_lines(ledger, &items, &ignored, &sioul_core::words::Words::of(&s.config));
655    let shown = view::budgets(ledger, &lines, &s.tr, now.date());
656    if shown.mail.is_empty() {
657        return;
658    }
659    println!("\n{}", shown.mail_title);
660    for row in &shown.mail {
661        let budget = row.budget.as_deref().map_or("?", |b| ledger.budget_title(b));
662        let amount = if row.amount.is_empty() { "—" } else { row.amount.as_str() };
663        println!("  {} · {} · {} · {amount} · {}", row.date, row.kind_label, one_line(&row.party), one_line(budget));
664        let doubtful = row.doubtful && row.state == "proposed";
665        for note in [row.note.clone(), if doubtful { s.tr.text("note-doubtful", None) } else { String::new() }] {
666            if !note.is_empty() {
667                println!("      {}", one_line(&note));
668            }
669        }
670    }
671}
672
673fn block_command(s: &Session, entry: Option<&str>) -> Result<(), String> {
674    let path = s.config.blocked_senders_path();
675    let Some(entry) = entry else {
676        // Addresses and patterns, then numbers and cards, as their lines write them.
677        let list = SenderList::load(&path);
678        let entries: Vec<String> = list.entries().into_iter().chain(list.numbers().iter().map(|n| format!("{}{n}", porch::TEL))).chain(list.cards().iter().map(|c| format!("{}{c}", porch::CONTACT))).collect();
679        if entries.is_empty() {
680            println!("{}", s.tr.text("blocked-none", None));
681        }
682        for e in entries {
683            println!("{}", one_line(&e));
684        }
685        return Ok(());
686    };
687    porch::set_standing(&s.config, entry, porch::Standing::Blocked)?;
688    println!("{}", s.say("ui-blocked", &[("entry", entry.trim().to_ascii_lowercase())]));
689    Ok(())
690}
691
692fn verify_command(s: &Session, again: bool) -> Result<(), String> {
693    for source in s.config.mail_sources() {
694        let checked = sioul_sync::verify::verify_folder(&source.folder, again);
695        let mut args = s.tr.counted(checked);
696        args.set("account", source.account.unwrap_or_default());
697        println!("{}", s.tr.text("verify-done", Some(&args)));
698    }
699    Ok(())
700}
701
702fn unblock_command(s: &Session, entry: &str) -> Result<(), String> {
703    // Out of the blocked list; neutral when a category or a domain would still block it.
704    porch::unblock(&s.config, entry)?;
705    println!("{}", s.say("ui-unblocked", &[("entry", entry.trim().to_ascii_lowercase())]));
706    Ok(())
707}
708
709pub(crate) fn date(s: &Session, seconds: Option<i64>) -> String {
710    seconds
711        .and_then(|sec| Timestamp::from_second(sec).ok())
712        .map_or_else(|| s.tr.text("no-date", None), |t| s.tr.date(&t.to_zoned(TimeZone::system()), true))
713}
714
715#[cfg(test)]
716mod terminal_tests {
717    use super::*;
718
719    #[test]
720    fn what_is_printed_cannot_drive_the_terminal() {
721        // A subject with a title change, a clipboard write (OSC 52) and a right-to-left mark.
722        let subject = "Invoice\u{1b}]0;pwned\u{7}\u{1b}]52;c;Y2F0\u{7} \u{202e}fdp.exe\r\nBcc: x";
723        let line = one_line(subject);
724        assert!(!line.contains(['\u{1b}', '\u{7}', '\u{202e}', '\r', '\n']), "{line:?}");
725        let lines = plain_lines("First line\r\nsecond\u{1b}[2J\rthird\ttab");
726        assert_eq!(lines, "First line\nsecond[2J\nthird\ttab");
727    }
728
729    #[test]
730    fn numbers_choose_the_plural() {
731        let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
732        let one = s.say("shield-read", &[("n", "1".to_string()), ("left", "0".to_string())]);
733        assert!(one.contains("1 message;"), "{one}");
734        let many = s.say("shield-read", &[("n", "2".to_string()), ("left", "0".to_string())]);
735        assert!(many.contains("2 messages;"), "{many}");
736    }
737}